Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrate course storage with course management #47

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7675dd4
Allow public access to video API endpoints
ahmedayman4a Dec 18, 2024
694d55e
Add ObjectMapper configuration for JSON processing
ahmedayman4a Dec 18, 2024
a2015cb
Refactor file and video upload methods to use MultipartFile directly …
ahmedayman4a Dec 18, 2024
c2f4934
Update Cloudinary configuration and enable multipart file uploads wit…
ahmedayman4a Dec 18, 2024
e7da354
Add cloudinary-video-player dependency to frontend
ahmedayman4a Dec 18, 2024
5ce1532
Add video upload and playback functionality with Cloudinary integration
ahmedayman4a Dec 18, 2024
97a1583
Remove objectmapping as it caused failed json parsing
ahmedayman4a Dec 18, 2024
83f2f8a
Add nullable constraint and length limit to Video URL field
ahmedayman4a Jan 1, 2025
85ef529
Refactor Lesson entity to replace videoLink with a Video entity refer…
ahmedayman4a Jan 1, 2025
e2c0fbf
Remove videoLink update from LessonService during lesson update
ahmedayman4a Jan 1, 2025
15c61f7
Update ModuleService to use Video entity URL instead of videoLink
ahmedayman4a Jan 1, 2025
e564cb1
Merge branch 'devel' into KTP-60-Implement-Course-Storage-in-Front-End
ahmedayman4a Jan 1, 2025
ceba7dd
Merge branch 'KTP-60-Implement-Course-Storage-in-Front-End' into KTP-…
ahmedayman4a Jan 1, 2025
a07eab3
Implement video upload for specific lessons in CourseStorageController
ahmedayman4a Jan 1, 2025
78b463b
Add ForbiddenException and handler to GlobalExceptionHandler
ahmedayman4a Jan 1, 2025
54a2c65
Refactor storeVideo method to return Video entity instead of VideoRes…
ahmedayman4a Jan 1, 2025
b99c2d9
Implement video update functionality in LessonService with proper aut…
ahmedayman4a Jan 1, 2025
d377b63
Refactor video upload tests with lesson ID handling and user setup
ahmedayman4a Jan 1, 2025
de77d6a
Add lesson ID validation to VideoUploadDTO tests
ahmedayman4a Jan 1, 2025
048d9d2
Add unit tests for updateLessonVideo method in LessonService
ahmedayman4a Jan 1, 2025
7f46bdd
Merge branch 'devel' into KTP-88-Integrate-Course-Storage-with-Course…
ahmedayman4a Jan 1, 2025
1878df5
Update package-lock.json to include 'modal' and 'm3u8-parser' depende…
ahmedayman4a Jan 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers("/api/auth/**").permitAll() // Allow all requests to /api/auth/**
.requestMatchers("/api/courses/search/**").permitAll() // Allow all requests to /api/courses/search/**
.requestMatchers("/api/courses/preview/**").permitAll() // Allow all requests to /api/courses/search/**
.requestMatchers("/api/videos/**").permitAll()

// Protected endpoints
.requestMatchers("/api/admin/**").hasRole("ADMIN") // Allow only users with role ADMIN to access /api/admin/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package com.csed.knowtopia.controller;

import com.csed.knowtopia.dto.LessonDTO;
import com.csed.knowtopia.dto.request.FileUploadDTO;
import com.csed.knowtopia.dto.request.VideoUploadDTO;
import com.csed.knowtopia.dto.response.FileResponseDTO;
import com.csed.knowtopia.dto.response.VideoResponseDTO;
import com.csed.knowtopia.exception.UnauthorizedException;
import com.csed.knowtopia.service.CourseStorageService;
import com.csed.knowtopia.service.LessonService;
import com.csed.knowtopia.service.CustomUserPrincipal;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
Expand All @@ -14,7 +18,11 @@
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.security.core.AuthenticationException;
import org.springframework.http.HttpStatus;

import java.io.IOException;

Expand All @@ -24,13 +32,15 @@
@Tag(name = "Course Storage", description = "APIs for managing course files and videos")
public class CourseStorageController {
private final CourseStorageService storageService;
private final LessonService lessonService;

@Operation(summary = "Upload a file", description = "Upload a file for a course with optional description")
@ApiResponse(responseCode = "200", description = "File uploaded successfully",
content = @Content(schema = @Schema(implementation = FileResponseDTO.class)))
@ApiResponse(responseCode = "400", description = "Invalid file or request")
@PostMapping("/files")
public ResponseEntity<FileResponseDTO> uploadFile(@Valid @ModelAttribute FileUploadDTO fileDTO) throws IOException {
public ResponseEntity<FileResponseDTO> uploadFile(@RequestParam("file") MultipartFile file) throws IOException {
FileUploadDTO fileDTO = new FileUploadDTO(file, null);
return ResponseEntity.ok(storageService.storeFile(fileDTO));
}

Expand All @@ -50,28 +60,26 @@ public ResponseEntity<String> deleteFile(@PathVariable long id) {
return ResponseEntity.ok(storageService.removeFile(id));
}

@Operation(summary = "Upload a video", description = "Upload a video for a course with optional description")
@Operation(summary = "Upload a video to a lesson", description = "Upload a video for a specific lesson")
@ApiResponse(responseCode = "200", description = "Video uploaded successfully",
content = @Content(schema = @Schema(implementation = VideoResponseDTO.class)))
content = @Content(schema = @Schema(implementation = LessonDTO.class)))
@ApiResponse(responseCode = "400", description = "Invalid video or request")
@PostMapping("/videos")
public ResponseEntity<VideoResponseDTO> uploadVideo(@Valid @ModelAttribute VideoUploadDTO videoDTO) throws IOException {
return ResponseEntity.ok(storageService.storeVideo(videoDTO));
@ApiResponse(responseCode = "401", description = "Unauthorized")
@ApiResponse(responseCode = "404", description = "Lesson not found")
@PostMapping("/lessons/{lessonId}/video")
public ResponseEntity<?> uploadLessonVideo(
@PathVariable Long lessonId,
@RequestParam("file") MultipartFile video,
@AuthenticationPrincipal CustomUserPrincipal principal
) throws IOException {
if (principal == null) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("Authentication required");
}
VideoUploadDTO videoDTO = new VideoUploadDTO(video, lessonId, null);
return ResponseEntity.ok(lessonService.updateLessonVideo(videoDTO, principal.getUser()));
}

@Operation(summary = "Get video metadata", description = "Retrieve metadata for a specific video")
@ApiResponse(responseCode = "200", description = "Video metadata retrieved successfully")
@ApiResponse(responseCode = "404", description = "Video not found")
@GetMapping("/videos/{id}")
public ResponseEntity<VideoResponseDTO> getVideoMetadata(@PathVariable long id) {
return ResponseEntity.ok(storageService.retrieveVideoMetadata(id));
}

@Operation(summary = "Delete a video", description = "Delete a specific video by ID")
@ApiResponse(responseCode = "200", description = "Video deleted successfully")
@ApiResponse(responseCode = "404", description = "Video not found")
@DeleteMapping("/videos/{id}")
public ResponseEntity<String> deleteVideo(@PathVariable long id) {
return ResponseEntity.ok(storageService.removeVideo(id));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ public class VideoUploadDTO {
@NotNull(message = "File is required")
private MultipartFile file;

@NotNull(message = "LessonId is required")
@Schema(description = "Id of the lesson to which the video belongs")
private Long lessonId;

@Schema(description = "Optional description of the video")
@Nullable
private String description;
Expand Down
11 changes: 6 additions & 5 deletions backend/src/main/java/com/csed/knowtopia/entity/Lesson.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ public class Lesson {
@JoinColumn(name = "module_id", nullable = false)
private Module module;

@NotEmpty
@Column(nullable = false, length = 2083)
private String videoLink;
@OneToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "video_id")
private Video video;

@PastOrPresent
@Column(name = "creation_date")
Expand All @@ -55,8 +56,8 @@ public class Lesson {
public Module getModule() { return module; }
public void setModule(Module module) { this.module = module; }

public String getVideoLink() { return videoLink; }
public void setVideoLink(String videoLink) { this.videoLink = videoLink; }
public Video getVideo() { return video; }
public void setVideo(Video video) { this.video = video; }

public LocalDate getCreationDate() { return creationDate; }
public void setCreationDate(LocalDate creationDate){ this.creationDate = creationDate; }
Expand Down
3 changes: 3 additions & 0 deletions backend/src/main/java/com/csed/knowtopia/entity/Video.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ public class Video {
private Long id;

private String filename;

@Column(nullable = false, length = 2083)
private String url;

private String description;

@Column(updatable = false)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.csed.knowtopia.exception;

public class ForbiddenException extends RuntimeException {
public ForbiddenException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.csed.knowtopia.handler;

import com.csed.knowtopia.exception.ErrorResponse;
import com.csed.knowtopia.exception.UnauthorizedException;
import com.csed.knowtopia.exception.*;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand All @@ -10,9 +9,6 @@
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import com.csed.knowtopia.exception.FileValidationException;
import com.csed.knowtopia.exception.ResourceNotFoundException;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
Expand Down Expand Up @@ -60,4 +56,15 @@ public ResponseEntity<ErrorResponse> handleUnauthorizedException(UnauthorizedExc
);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
}

@ExceptionHandler(ForbiddenException.class)
public ResponseEntity<ErrorResponse> handleForbiddenException(ForbiddenException ex) {
ErrorResponse errorResponse = new ErrorResponse(
HttpStatus.FORBIDDEN.value(),
ex.getMessage(),
null,
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public FileResponseDTO storeFile(FileUploadDTO fileDTO) throws IOException {
.build();
}

public VideoResponseDTO storeVideo(VideoUploadDTO videoDTO) throws IOException {
public Video storeVideo(VideoUploadDTO videoDTO) throws IOException {
fileValidationService.validateVideo(videoDTO.getFile());

String originalFilename = videoDTO.getFile().getOriginalFilename();
Expand All @@ -60,13 +60,7 @@ public VideoResponseDTO storeVideo(VideoUploadDTO videoDTO) throws IOException {
video.setUrl((String) uploadResult.get("url"));
video.setDescription(videoDTO.getDescription());

video = videoRepository.save(video);
return VideoResponseDTO.builder()
.id(video.getId())
.filename(video.getFilename())
.url(video.getUrl())
.description(video.getDescription())
.build();
return videoRepository.save(video);
}

public FileResponseDTO retrieveFileMetadata(long id) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,39 @@
package com.csed.knowtopia.service;

import com.csed.knowtopia.dto.LessonDTO;
import com.csed.knowtopia.dto.request.VideoUploadDTO;
import com.csed.knowtopia.entity.Course;
import com.csed.knowtopia.entity.Lesson;
import com.csed.knowtopia.entity.Module;
import com.csed.knowtopia.entity.User;
import com.csed.knowtopia.entity.Video;
import com.csed.knowtopia.exception.ForbiddenException;
import com.csed.knowtopia.exception.UnauthorizedException;
import com.csed.knowtopia.repository.LessonRepository;
import com.csed.knowtopia.repository.ModuleRepository;
import com.csed.knowtopia.repository.VideoRepository;
import jakarta.persistence.EntityNotFoundException;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.io.IOException;

@Service
public class LessonService {
private final ModelMapper modelMapper;
private final ModuleRepository moduleRepository;
private final LessonRepository lessonRepository;
private final CourseStorageService courseStorageService;
private final VideoRepository videoRepository;

public LessonService(LessonRepository lessonRepository, ModelMapper modelMapper, ModuleRepository moduleRepository) {
public LessonService(LessonRepository lessonRepository, ModelMapper modelMapper,
ModuleRepository moduleRepository, CourseStorageService courseStorageService, VideoRepository videoRepository) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is nice that you noticed to modify it in the update endpoint.

this.modelMapper = modelMapper;
this.moduleRepository = moduleRepository;
this.lessonRepository = lessonRepository;
this.courseStorageService = courseStorageService;
this.videoRepository = videoRepository;
}


Expand All @@ -35,7 +45,6 @@ private Lesson updateEntries(Lesson oldLesson, Lesson newLesson) {
if (newLesson.getTitle() != null) oldLesson.setTitle(newLesson.getTitle());
if (newLesson.getDescription() != null) oldLesson.setDescription(newLesson.getDescription());
if (newLesson.getOrderIndex() != null) oldLesson.setOrderIndex(newLesson.getOrderIndex());
if (newLesson.getVideoLink() != null) oldLesson.setVideoLink(newLesson.getVideoLink());
return oldLesson;
}

Expand Down Expand Up @@ -86,7 +95,40 @@ public LessonDTO deleteLesson(Long lessonId, User user) {

User courseInstructor = course.getInstructor();
if (!user.getId().equals(courseInstructor.getId())) throw new UnauthorizedException("Unauthorized");
// Delete old video if exists
if (lesson.getVideo() != null) {
courseStorageService.removeVideo(lesson.getVideo().getId());
videoRepository.delete(lesson.getVideo());
}
lessonRepository.delete(lesson);
return modelMapper.map(lesson, LessonDTO.class);
}

public LessonDTO updateLessonVideo(VideoUploadDTO videoDTO, User user) throws IOException {
Lesson lesson = lessonRepository.findById(videoDTO.getLessonId())
.orElseThrow(() -> new EntityNotFoundException("Lesson not found"));

Module module = lesson.getModule();
if (module == null) throw new EntityNotFoundException("Module not found");

Course course = module.getCourse();
if (course == null) throw new EntityNotFoundException("Course not found");

User instructor = course.getInstructor();
if (!user.getId().equals(instructor.getId()))
throw new ForbiddenException("Unauthorized to update lesson video");

// Delete old video if exists
if (lesson.getVideo() != null) {
courseStorageService.removeVideo(lesson.getVideo().getId());
}

// Store new video
Video video = courseStorageService.storeVideo(videoDTO);
lesson.setVideo(video);

LessonDTO lessonDTO = modelMapper.map(lessonRepository.save(lesson), LessonDTO.class);
lessonDTO.setVideoLink(video.getUrl());
return lessonDTO;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public CourseModuleDTO getCourseModules(Long courseId) {
.id(lesson.getId())
.title(lesson.getTitle())
.orderIndex(lesson.getOrderIndex())
.videoLink(lesson.getVideoLink())
.videoLink(lesson.getVideo().getUrl())
.description(lesson.getDescription())
.creationDate(lesson.getCreationDate())
.moduleId(lesson.getModule().getId())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public void validateVideo(MultipartFile file) {
}

private void validateFile(MultipartFile file, List<String> allowedTypes, long maxSize) {
if (file.isEmpty()) {
if (file == null || file.isEmpty()) {
throw new FileValidationException("File is empty");
}
if (file.getSize() > maxSize) {
Expand Down
7 changes: 6 additions & 1 deletion backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,9 @@ spring.security.oauth2.client.registration.google.client-secret=GOCSPX-Uda9XEuVp
# Cloudinary Configuration
cloudinary.cloud-name=dfwyg3lbr
cloudinary.api-key=764171541677637
cloudinary.api-secret=nzfm9vGh-bBCfyKCSQkKwnZ26aE
cloudinary.api-secret=nzfm9vGh-bBCfyKCSQkKwnZ26aE

spring.servlet.multipart.max-file-size=100MB
spring.servlet.multipart.max-request-size=100MB
spring.servlet.multipart.enabled=true
spring.servlet.multipart.resolve-lazily=true
Loading
Loading