diff --git a/src/main/java/learning/platform/config/SecurityConfiguration.java b/src/main/java/learning/platform/config/SecurityConfiguration.java index 078920c..1a24179 100644 --- a/src/main/java/learning/platform/config/SecurityConfiguration.java +++ b/src/main/java/learning/platform/config/SecurityConfiguration.java @@ -62,6 +62,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.GET, "/api/users/**").hasRole("ADMIN") .requestMatchers(HttpMethod.GET, "/api/courses/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") + .requestMatchers(HttpMethod.GET, "/api/courses").permitAll() + .requestMatchers(HttpMethod.GET, "/api/courses/**").permitAll(); + // Rutas que requieren autenticación con roles específicos (Agregadas al final) + req.requestMatchers(HttpMethod.POST, "/api/courses").hasRole("INSTRUCTOR") + .requestMatchers(HttpMethod.PUT, "/api/courses/**").hasRole("INSTRUCTOR") + .requestMatchers(HttpMethod.DELETE, "/api/courses/**").hasAnyRole("INSTRUCTOR", "ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/courses/**").hasRole("ADMIN") .requestMatchers("/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/webjars/**", "/swagger-resources/**").permitAll(); req.anyRequest().authenticated(); }) .headers(headers -> headers diff --git a/src/main/java/learning/platform/controller/CourseController.java b/src/main/java/learning/platform/controller/CourseController.java index c243a58..15a8847 100644 --- a/src/main/java/learning/platform/controller/CourseController.java +++ b/src/main/java/learning/platform/controller/CourseController.java @@ -76,6 +76,18 @@ public ResponseEntity updateCourse(@Valid @PathVariable Long return ResponseEntity.ok(courseService.updateCourse(id, dto, user)); } + /** + * Publicar o despublicar un curso (Solamente puede el ADMIN) + */ + @PatchMapping("/{id}/publish") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity publishCourse( + @PathVariable Long id, + @RequestParam boolean published + ) { + return ResponseEntity.ok(courseService.publishCourse(id, published)); + } + /** * Delete a course (INSTRUCTOR or ADMIN) */ diff --git a/src/main/java/learning/platform/controller/QuizController.java b/src/main/java/learning/platform/controller/QuizController.java index f1b5379..09c80f1 100644 --- a/src/main/java/learning/platform/controller/QuizController.java +++ b/src/main/java/learning/platform/controller/QuizController.java @@ -11,9 +11,12 @@ import learning.platform.dto.QuizResponse; import learning.platform.dto.QuizSubmissionRequest; import learning.platform.dto.QuizSubmissionResponse; +import learning.platform.entity.User; import learning.platform.service.QuizService; +import org.springframework.http.HttpStatus; 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 java.util.List; @@ -92,8 +95,9 @@ public ResponseEntity getQuizByLesson( @PostMapping("/{quizId}/submit") public ResponseEntity submitQuiz( @Parameter(description = "ID del quiz a resolver") @PathVariable Long quizId, - @Valid @RequestBody QuizSubmissionRequest request) { - QuizSubmissionResponse response = quizService.submitQuiz(quizId, request); + @Valid @RequestBody QuizSubmissionRequest request, + @AuthenticationPrincipal User student) { + QuizSubmissionResponse response = quizService.submitQuiz(quizId, request, student); return ResponseEntity.ok(response); } diff --git a/src/main/java/learning/platform/dto/CourseRequestDTO.java b/src/main/java/learning/platform/dto/CourseRequestDTO.java index c37c89e..9f7ba80 100644 --- a/src/main/java/learning/platform/dto/CourseRequestDTO.java +++ b/src/main/java/learning/platform/dto/CourseRequestDTO.java @@ -16,17 +16,13 @@ public class CourseRequestDTO { @NotBlank(message = "La categoría no puede estar vacía.") private String category; - @NotNull(message = "El estado de publicación es requerido.") - private Boolean published; - @Size(max = 150) private String urlPhoto; @Size(max = 255) private String about; - //Agregamos los getters y setters - + //getters y setters public @Size(max = 150) String getUrlPhoto() { return urlPhoto; @@ -68,11 +64,4 @@ public void setCategory(String category) { this.category = category; } - public Boolean getPublished() { - return published; - } - - public void setPublished(Boolean published) { - this.published = published; - } } \ No newline at end of file diff --git a/src/main/java/learning/platform/dto/QuizResponse.java b/src/main/java/learning/platform/dto/QuizResponse.java index f88e20e..320724c 100644 --- a/src/main/java/learning/platform/dto/QuizResponse.java +++ b/src/main/java/learning/platform/dto/QuizResponse.java @@ -25,6 +25,9 @@ public class QuizResponse { @Schema(description = "ID del curso asociado", example = "10") private Long courseId; + @Schema(description = "ID de la lección asociada", example = "20") + private Long lessonId; + @Schema(description = "Preguntas del quiz") private List questions; @@ -41,6 +44,9 @@ public class QuizResponse { public Long getCourseId() { return courseId; } public void setCourseId(Long courseId) { this.courseId = courseId; } + public Long getLessonId() { return lessonId; } + public void setLessonId(Long lessonId) { this.lessonId = lessonId; } + public List getQuestions() { return questions; } public void setQuestions(List questions) { this.questions = questions; } -} \ No newline at end of file +} diff --git a/src/main/java/learning/platform/dto/QuizSubmissionRequest.java b/src/main/java/learning/platform/dto/QuizSubmissionRequest.java index 50a3d5f..1251236 100644 --- a/src/main/java/learning/platform/dto/QuizSubmissionRequest.java +++ b/src/main/java/learning/platform/dto/QuizSubmissionRequest.java @@ -1,37 +1,35 @@ package learning.platform.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; + import java.util.List; +@Schema( + name = "Solicitud de Envío de Quiz", + description = "DTO para enviar las respuestas del quizz." +) public class QuizSubmissionRequest { - @NotNull(message = "El enrollmentId es obligatorio") - private Long enrollmentId; - @NotEmpty(message = "Debe enviar al menos una respuesta") - private List answers; + @NotNull(message = "La lista de respuestas no puede ser nula") + @Schema(description = "Lista de respuestas del estudiante") + private List answers; public QuizSubmissionRequest() {} - public QuizSubmissionRequest(Long enrollmentId, List answers) { - this.enrollmentId = enrollmentId; + public QuizSubmissionRequest(List answers) { this.answers = answers; } - public Long getEnrollmentId() { - return enrollmentId; - } - - public void setEnrollmentId(Long enrollmentId) { - this.enrollmentId = enrollmentId; - } + // --- Getters y Setters --- - public List getAnswers() { + public List getAnswers() { return answers; } - public void setAnswers(List answers) { + public void setAnswers(List answers) { this.answers = answers; } -} +} \ No newline at end of file diff --git a/src/main/java/learning/platform/entity/AnswerOption.java b/src/main/java/learning/platform/entity/AnswerOption.java new file mode 100644 index 0000000..a0b80b2 --- /dev/null +++ b/src/main/java/learning/platform/entity/AnswerOption.java @@ -0,0 +1,26 @@ +package learning.platform.entity; + +import jakarta.persistence.*; + +@Entity +@Table(name = "answer_options") +public class AnswerOption { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id", nullable = false) + private Question question; + + @Column(nullable = false, length = 1000) + private String text; + + // Getters y Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Question getQuestion() { return question; } + public void setQuestion(Question question) { this.question = question; } + public String getText() { return text; } + public void setText(String text) { this.text = text; } +} \ No newline at end of file diff --git a/src/main/java/learning/platform/entity/AnswerSubmission.java b/src/main/java/learning/platform/entity/AnswerSubmission.java new file mode 100644 index 0000000..a9e249a --- /dev/null +++ b/src/main/java/learning/platform/entity/AnswerSubmission.java @@ -0,0 +1,40 @@ +package learning.platform.entity; + +import jakarta.persistence.*; + +@Entity +@Table(name = "answer_submissions") +public class AnswerSubmission { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "quiz_submission_id", nullable = false) + private QuizSubmission quizSubmission; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id", nullable = false) + private Question question; + + // For multiple-choice questions + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "selected_option_id") + private AnswerOption selectedOption; + + // For open-ended questions (not yet implemented in DTOs) + @Column(length = 2000) + private String answerText; + + // Getters y Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public QuizSubmission getQuizSubmission() { return quizSubmission; } + public void setQuizSubmission(QuizSubmission quizSubmission) { this.quizSubmission = quizSubmission; } + public Question getQuestion() { return question; } + public void setQuestion(Question question) { this.question = question; } + public AnswerOption getSelectedOption() { return selectedOption; } + public void setSelectedOption(AnswerOption selectedOption) { this.selectedOption = selectedOption; } + public String getAnswerText() { return answerText; } + public void setAnswerText(String answerText) { this.answerText = answerText; } +} \ No newline at end of file diff --git a/src/main/java/learning/platform/entity/Course.java b/src/main/java/learning/platform/entity/Course.java index 8daa5ef..eb94fd0 100644 --- a/src/main/java/learning/platform/entity/Course.java +++ b/src/main/java/learning/platform/entity/Course.java @@ -2,10 +2,10 @@ import jakarta.persistence.*; -// Se elimina @Data de Lombok @Entity @Table(name = "courses") -public class Course extends Profile { +public class Course { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -13,16 +13,28 @@ public class Course extends Profile { @Column(nullable = false, unique = true) private String slug; + @Column(length = 100, nullable = false) private String title; + private String description; + + @Column(length = 25, nullable = false) private String category; + + // Se corrige el nombre del atributo para que sea el mismo que en el DTO + @Column(name = "profile_photo", length = 150) + private String urlPhoto; + + @Column(length = 255) + private String about; + private boolean published = false; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "instructor_id", nullable = false) private User instructor; - // --- Getters y Setters añadidos manualmente --- + // --- Getters y Setters corregidos --- public Long getId() { return id; @@ -79,4 +91,21 @@ public User getInstructor() { public void setInstructor(User instructor) { this.instructor = instructor; } + + // Se corrige el nombre del getter y setter + public String getUrlPhoto() { + return urlPhoto; + } + + public void setUrlPhoto(String urlPhoto) { + this.urlPhoto = urlPhoto; + } + + public String getAbout() { + return about; + } + + public void setAbout(String about) { + this.about = about; + } } \ No newline at end of file diff --git a/src/main/java/learning/platform/entity/Enrollment.java b/src/main/java/learning/platform/entity/Enrollment.java index cd08c0d..5d97c34 100644 --- a/src/main/java/learning/platform/entity/Enrollment.java +++ b/src/main/java/learning/platform/entity/Enrollment.java @@ -42,6 +42,10 @@ public Long getId() { return id; } + public void setId(Long id) { + this.id = id; + } + public User getStudent() { return student; } diff --git a/src/main/java/learning/platform/entity/Question.java b/src/main/java/learning/platform/entity/Question.java new file mode 100644 index 0000000..96c3bd1 --- /dev/null +++ b/src/main/java/learning/platform/entity/Question.java @@ -0,0 +1,48 @@ +package learning.platform.entity; + +import jakarta.persistence.*; +import java.util.List; + +@Entity +@Table(name = "questions") +public class Question { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "quiz_id", nullable = false) + private Quiz quiz; + + @Column(nullable = false, length = 2000) + private String text; + + // A question has many options. + @OneToMany(mappedBy = "question", cascade = CascadeType.ALL, orphanRemoval = true) + private List options; + + @Column(name = "correct_option_index", nullable = false) + private Integer correctOptionIndex; + + @Column + private Integer points; + + @Column(name = "time_limit_seconds") + private Integer timeLimitSeconds; + + // Getters y Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Quiz getQuiz() { return quiz; } + public void setQuiz(Quiz quiz) { this.quiz = quiz; } + public String getText() { return text; } + public void setText(String text) { this.text = text; } + public List getOptions() { return options; } + public void setOptions(List options) { this.options = options; } + public Integer getCorrectOptionIndex() { return correctOptionIndex; } + public void setCorrectOptionIndex(Integer correctOptionIndex) { this.correctOptionIndex = correctOptionIndex; } + public Integer getPoints() { return points; } + public void setPoints(Integer points) { this.points = points; } + public Integer getTimeLimitSeconds() { return timeLimitSeconds; } + public void setTimeLimitSeconds(Integer timeLimitSeconds) { this.timeLimitSeconds = timeLimitSeconds; } +} \ No newline at end of file diff --git a/src/main/java/learning/platform/entity/Quiz.java b/src/main/java/learning/platform/entity/Quiz.java new file mode 100644 index 0000000..ed8688a --- /dev/null +++ b/src/main/java/learning/platform/entity/Quiz.java @@ -0,0 +1,200 @@ +package learning.platform.entity; + +import jakarta.persistence.*; +import java.util.List; +import java.time.LocalDateTime; + +/** + * Representa un cuestionario o examen dentro de la plataforma de aprendizaje. + * Un Quiz puede estar asociado a un curso o a una lección, pero no a ambos. + * Contiene una lista de preguntas. + */ +@Entity +@Table(name = "quizzes") +public class Quiz { + /** + * Identificador único del quiz. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * Título del quiz. No puede ser nulo y tiene un tamaño máximo de 255 caracteres. + */ + @Column(nullable = false, length = 255) + private String title; + + /** + * Descripción opcional del quiz, proporcionando contexto adicional. + */ + @Column(length = 1000) + private String description; + + /** + * Relación con la entidad {@link Course}. Un quiz puede pertenecer a un curso. + * Esta relación es opcional. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "course_id") + private Course course; + + /** + * Relación con la entidad {@link Lesson}. Un quiz puede pertenecer a una lección. + * Esta relación es opcional y excluyente con la de 'course'. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "lesson_id") + private Lesson lesson; + + /** + * Lista de preguntas asociadas a este quiz. + * La cascada de tipo ALL asegura que las operaciones (guardar, actualizar, eliminar) + * se propaguen a las preguntas y sus opciones. 'orphanRemoval' elimina preguntas + * que ya no están asociadas a un quiz. + */ + @OneToMany(mappedBy = "quiz", cascade = CascadeType.ALL, orphanRemoval = true) + private List questions; + + /** + * Fecha y hora de creación del quiz. Se establece automáticamente al crear la entidad. + */ + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + /** + * Fecha y hora de la última actualización del quiz. + */ + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + // --- Getters y Setters --- + + /** + * Obtiene el identificador único del quiz. + * @return El ID del quiz. + */ + public Long getId() { + return id; + } + + /** + * Establece el identificador único del quiz. + * @param id El ID a establecer. + */ + public void setId(Long id) { + this.id = id; + } + + /** + * Obtiene el título del quiz. + * @return El título del quiz. + */ + public String getTitle() { + return title; + } + + /** + * Establece el título del quiz. + * @param title El título a establecer. + */ + public void setTitle(String title) { + this.title = title; + } + + /** + * Obtiene la descripción del quiz. + * @return La descripción del quiz. + */ + public String getDescription() { + return description; + } + + /** + * Establece la descripción del quiz. + * @param description La descripción a establecer. + */ + public void setDescription(String description) { + this.description = description; + } + + /** + * Obtiene el curso asociado al quiz. + * @return La entidad {@link Course} asociada. + */ + public Course getCourse() { + return course; + } + + /** + * Establece el curso asociado al quiz. + * @param course La entidad {@link Course} a asociar. + */ + public void setCourse(Course course) { + this.course = course; + } + + /** + * Obtiene la lección asociada al quiz. + * @return La entidad {@link Lesson} asociada. + */ + public Lesson getLesson() { + return lesson; + } + + /** + * Establece la lección asociada al quiz. + * @param lesson La entidad {@link Lesson} a asociar. + */ + public void setLesson(Lesson lesson) { + this.lesson = lesson; + } + + /** + * Obtiene la lista de preguntas del quiz. + * @return La lista de entidades {@link Question}. + */ + public List getQuestions() { + return questions; + } + + /** + * Establece la lista de preguntas para el quiz. + * @param questions La lista de entidades {@link Question} a establecer. + */ + public void setQuestions(List questions) { + this.questions = questions; + } + + /** + * Obtiene la fecha y hora de creación del quiz. + * @return La fecha de creación. + */ + public LocalDateTime getCreatedAt() { + return createdAt; + } + + /** + * Establece la fecha y hora de creación del quiz. + * @param createdAt La fecha de creación a establecer. + */ + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + /** + * Obtiene la fecha y hora de la última actualización del quiz. + * @return La fecha de actualización. + */ + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + /** + * Establece la fecha y hora de la última actualización del quiz. + * @param updatedAt La fecha de actualización a establecer. + */ + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/src/main/java/learning/platform/entity/QuizSubmission.java b/src/main/java/learning/platform/entity/QuizSubmission.java new file mode 100644 index 0000000..d0b77fd --- /dev/null +++ b/src/main/java/learning/platform/entity/QuizSubmission.java @@ -0,0 +1,54 @@ +package learning.platform.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "quiz_submissions") +public class QuizSubmission { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "quiz_id", nullable = false) + private Quiz quiz; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; // The student who submitted the quiz + + @Column(nullable = false) + private Integer score; + + @Column(nullable = false) + private Double percentage; + + @Column(nullable = false) + private Boolean passed; + + @Column(name = "submitted_at", nullable = false) + private LocalDateTime submittedAt = LocalDateTime.now(); + + @OneToMany(mappedBy = "quizSubmission", cascade = CascadeType.ALL, orphanRemoval = true) + private List answers; + + // Getters y Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Quiz getQuiz() { return quiz; } + public void setQuiz(Quiz quiz) { this.quiz = quiz; } + public User getUser() { return user; } + public void setUser(User user) { this.user = user; } + public Integer getScore() { return score; } + public void setScore(Integer score) { this.score = score; } + public Double getPercentage() { return percentage; } + public void setPercentage(Double percentage) { this.percentage = percentage; } + public Boolean getPassed() { return passed; } + public void setPassed(Boolean passed) { this.passed = passed; } + public LocalDateTime getSubmittedAt() { return submittedAt; } + public void setSubmittedAt(LocalDateTime submittedAt) { this.submittedAt = submittedAt; } + public List getAnswers() { return answers; } + public void setAnswers(List answers) { this.answers = answers; } +} \ No newline at end of file diff --git a/src/main/java/learning/platform/exception/NotFoundException.java b/src/main/java/learning/platform/exception/NotFoundException.java new file mode 100644 index 0000000..686d54e --- /dev/null +++ b/src/main/java/learning/platform/exception/NotFoundException.java @@ -0,0 +1,12 @@ +package learning.platform.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class NotFoundException extends RuntimeException { + + public NotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/learning/platform/mapper/CourseMapper.java b/src/main/java/learning/platform/mapper/CourseMapper.java index d87327f..d661f7b 100644 --- a/src/main/java/learning/platform/mapper/CourseMapper.java +++ b/src/main/java/learning/platform/mapper/CourseMapper.java @@ -5,45 +5,44 @@ import learning.platform.entity.Course; import org.springframework.stereotype.Component; -@Component // Lo marcamos como un componente de Spring para poder inyectarlo +@Component public class CourseMapper { // Convierte de DTO de petición a Entidad (para crear cursos) public Course toEntity(CourseRequestDTO dto) { + if (dto == null) { + return null; + } + Course course = new Course(); course.setTitle(dto.getTitle()); course.setDescription(dto.getDescription()); course.setCategory(dto.getCategory()); - course.setPublished(dto.getPublished() != null && dto.getPublished()); - - if (dto.getUrlPhoto() != null && !dto.getUrlPhoto().isEmpty()){ - course.setProfilePhoto(dto.getUrlPhoto()); - } - - if (dto.getAbout() != null && !dto.getAbout().isEmpty()){ - course.setAbout(dto.getAbout()); - } - + course.setUrlPhoto(dto.getUrlPhoto()); + course.setAbout(dto.getAbout()); return course; } // Convierte de Entidad a DTO de respuesta (para enviar al cliente) public CourseResponseDTO toResponseDTO(Course course) { + if (course == null) { + return null; + } + CourseResponseDTO dto = new CourseResponseDTO(); dto.setId(course.getId()); - dto.setTitle(course.getTitle()); dto.setSlug(course.getSlug()); + dto.setTitle(course.getTitle()); + dto.setDescription(course.getDescription()); + dto.setCategory(course.getCategory()); + dto.setUrlPhoto(course.getUrlPhoto()); // Se corrige el mapeo + dto.setAbout(course.getAbout()); + + // Manejo del instructor para evitar NullPointerException si es nulo if (course.getInstructor() != null) { dto.setInstructorName(course.getInstructor().getFullName()); } - if (course.getProfilePhoto() != null && !course.getProfilePhoto().isEmpty()){ - dto.setUrlPhoto(course.getProfilePhoto()); - } - - if (course.getAbout() != null && !course.getAbout().isEmpty()){ - dto.setAbout(course.getAbout()); - } return dto; } @@ -54,6 +53,7 @@ public void updateCourseFromDTO(CourseRequestDTO dto, Course course) { if (dto == null || course == null) { return; } + // Se actualizan los campos solo si no son nulos en el DTO if (dto.getTitle() != null) { course.setTitle(dto.getTitle()); } @@ -63,8 +63,11 @@ public void updateCourseFromDTO(CourseRequestDTO dto, Course course) { if (dto.getCategory() != null) { course.setCategory(dto.getCategory()); } - if (dto.getPublished() != null) { - course.setPublished(dto.getPublished()); + if (dto.getUrlPhoto() != null) { + course.setUrlPhoto(dto.getUrlPhoto()); + } + if (dto.getAbout() != null) { + course.setAbout(dto.getAbout()); } } } \ No newline at end of file diff --git a/src/main/java/learning/platform/mapper/QuizMapper.java b/src/main/java/learning/platform/mapper/QuizMapper.java new file mode 100644 index 0000000..b9c481f --- /dev/null +++ b/src/main/java/learning/platform/mapper/QuizMapper.java @@ -0,0 +1,97 @@ +package learning.platform.mapper; + +import learning.platform.dto.*; +import learning.platform.entity.*; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +/** + * Interfaz Mapper para la conversión de entidades relacionadas con quizzes a DTOs y viceversa. + * Utiliza el framework MapStruct para generar automáticamente la implementación en tiempo de compilación. + * Esto ayuda a mantener el código de conversión limpio y libre de errores manuales. + */ +@Mapper(componentModel = "spring") +public interface QuizMapper { + + /** + * Instancia singleton del mapper para uso en componentes que no son de Spring. + * Aunque se prefiere la inyección de dependencias en un entorno Spring. + */ + QuizMapper INSTANCE = Mappers.getMapper(QuizMapper.class); + + /** + * Convierte una entidad {@link Quiz} a un DTO {@link QuizResponse}. + * Mapea los IDs de las entidades relacionadas (Course, Lesson) directamente + * a los campos del DTO y mapea la lista de preguntas. + * @param quiz La entidad Quiz a convertir. + * @return El DTO QuizResponse resultante. + */ + @Mapping(target = "courseId", source = "course.id") + @Mapping(target = "lessonId", source = "lesson.id") + @Mapping(target = "questions", source = "questions") + QuizResponse toQuizResponse(Quiz quiz); + + /** + * Convierte una entidad {@link Question} a un DTO {@link QuestionResponse}. + * @param question La entidad Question a convertir. + * @return El DTO QuestionResponse resultante. + */ + @Mapping(target = "options", source = "options") + QuestionResponse toQuestionResponse(Question question); + + /** + * Convierte una entidad {@link AnswerOption} a un DTO {@link AnswerOptionDTO}. + * @param answerOption La entidad AnswerOption a convertir. + * @return El DTO AnswerOptionDTO resultante. + */ + AnswerOptionDTO toAnswerOptionDTO(AnswerOption answerOption); + + /** + * Convierte una solicitud de creación de quiz (DTO) a una entidad {@link Quiz}. + * El campo de preguntas se ignora en este mapeo para ser gestionado manualmente. + * @param request La solicitud de creación QuizCreateRequest. + * @return La entidad Quiz resultante. + */ + @Mapping(target = "questions", ignore = true) + Quiz toQuiz(QuizCreateRequest request); + + /** + * Convierte una solicitud de creación de pregunta (DTO) a una entidad {@link Question}. + * Las opciones se ignoran en este mapeo para ser gestionadas manualmente. + * @param request La solicitud de creación QuestionCreateRequest. + * @return La entidad Question resultante. + */ + @Mapping(target = "options", ignore = true) + Question toQuestion(QuestionCreateRequest request); + + /** + * Convierte un DTO de opción de respuesta a una entidad {@link AnswerOption}. + * @param dto El DTO AnswerOptionDTO a convertir. + * @return La entidad AnswerOption resultante. + */ + AnswerOption toAnswerOption(AnswerOptionDTO dto); + + /** + * Convierte una lista de entidades {@link Quiz} a una lista de DTOs {@link QuizResponse}. + * @param quizzes La lista de entidades Quiz. + * @return Una lista de DTOs QuizResponse. + */ + List toQuizResponseList(List quizzes); + + /** + * Convierte una lista de entidades {@link Question} a una lista de DTOs {@link QuestionResponse}. + * @param questions La lista de entidades Question. + * @return Una lista de DTOs QuestionResponse. + */ + List toQuestionResponseList(List questions); + + /** + * Convierte una lista de entidades {@link AnswerOption} a una lista de DTOs {@link AnswerOptionDTO}. + * @param answerOptions La lista de entidades AnswerOption. + * @return Una lista de DTOs AnswerOptionDTO. + */ + List toAnswerOptionDtoList(List answerOptions); +} \ No newline at end of file diff --git a/src/main/java/learning/platform/repository/AnswerOptionRepository.java b/src/main/java/learning/platform/repository/AnswerOptionRepository.java new file mode 100644 index 0000000..9f424c5 --- /dev/null +++ b/src/main/java/learning/platform/repository/AnswerOptionRepository.java @@ -0,0 +1,17 @@ +package learning.platform.repository; + +import learning.platform.entity.AnswerOption; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface AnswerOptionRepository extends JpaRepository { + + /** + * Busca todas las opciones de respuesta para una pregunta específica. + * @param questionId El ID de la pregunta. + * @return Una lista de AnswerOption. + */ + List findByQuestionId(Long questionId); +} diff --git a/src/main/java/learning/platform/repository/AnswerSubmissionRepository.java b/src/main/java/learning/platform/repository/AnswerSubmissionRepository.java new file mode 100644 index 0000000..7d94f28 --- /dev/null +++ b/src/main/java/learning/platform/repository/AnswerSubmissionRepository.java @@ -0,0 +1,26 @@ +package learning.platform.repository; + +import learning.platform.entity.AnswerSubmission; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Repositorio para la entidad {@link AnswerSubmission}. + * Proporciona métodos de acceso a datos para operaciones CRUD y consultas personalizadas + * relacionadas con las respuestas enviadas por los usuarios. + */ +@Repository +public interface AnswerSubmissionRepository extends JpaRepository { + + /** + * Busca todas las respuestas de un envío de quiz específico. + * Esta es una consulta personalizada que aprovecha las convenciones de nomenclatura + * de Spring Data JPA para generar la consulta SQL automáticamente. + * + * @param quizSubmissionId El ID de la entrega del quiz. + * @return Una lista de entidades {@link AnswerSubmission} asociadas a esa entrega. + */ + List findByQuizSubmissionId(Long quizSubmissionId); +} \ No newline at end of file diff --git a/src/main/java/learning/platform/repository/QuestionRepository.java b/src/main/java/learning/platform/repository/QuestionRepository.java new file mode 100644 index 0000000..b29dd81 --- /dev/null +++ b/src/main/java/learning/platform/repository/QuestionRepository.java @@ -0,0 +1,14 @@ +package learning.platform.repository; + +import learning.platform.entity.Question; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface QuestionRepository extends JpaRepository { + + // Encuentra todas las preguntas con un ID específico. + List findByQuizId(Long quizId); +} diff --git a/src/main/java/learning/platform/repository/QuizRepository.java b/src/main/java/learning/platform/repository/QuizRepository.java new file mode 100644 index 0000000..2b5267f --- /dev/null +++ b/src/main/java/learning/platform/repository/QuizRepository.java @@ -0,0 +1,18 @@ +package learning.platform.repository; + +import learning.platform.entity.Quiz; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.List; + +@Repository +public interface QuizRepository extends JpaRepository { + + //Encuentra los quizzes asociados con un id de algún curso. + List findByCourseId(Long courseId); + + // Encuentra quizz asociado a alguna lección. + Optional findByLessonId(Long lessonId); +} diff --git a/src/main/java/learning/platform/repository/QuizSubmissionRepository.java b/src/main/java/learning/platform/repository/QuizSubmissionRepository.java new file mode 100644 index 0000000..95c149b --- /dev/null +++ b/src/main/java/learning/platform/repository/QuizSubmissionRepository.java @@ -0,0 +1,14 @@ +package learning.platform.repository; + +import learning.platform.entity.QuizSubmission; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface QuizSubmissionRepository extends JpaRepository { + + //Encuentra el sumbit de un user id y el quizz id. + Optional findByUserIdAndQuizId(Long userId, Long quizId); +} diff --git a/src/main/java/learning/platform/service/CourseService.java b/src/main/java/learning/platform/service/CourseService.java index 91e9055..ede3b74 100644 --- a/src/main/java/learning/platform/service/CourseService.java +++ b/src/main/java/learning/platform/service/CourseService.java @@ -2,11 +2,12 @@ import learning.platform.dto.CourseRequestDTO; import learning.platform.dto.CourseResponseDTO; +import learning.platform.entity.Course; import learning.platform.entity.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -// Interfaz, solo definimos lo que se puede hacer + public interface CourseService { //Crear curso CourseResponseDTO createCourse(CourseRequestDTO dto, User instructor); @@ -20,4 +21,8 @@ public interface CourseService { CourseResponseDTO findCourseDtoById(Long id); //enrollar al estudiante en un curso, pero lo quitamos //void enrollStudentInCourse(Long courseId, User student); + // Buscar un curso por su ID y devolver la entidad + Course findCourseById(Long id); + // Método en la interfaz CourseService + CourseResponseDTO publishCourse(Long courseId, boolean published); } \ No newline at end of file diff --git a/src/main/java/learning/platform/service/QuizService.java b/src/main/java/learning/platform/service/QuizService.java index b5010ff..fc97301 100644 --- a/src/main/java/learning/platform/service/QuizService.java +++ b/src/main/java/learning/platform/service/QuizService.java @@ -1,23 +1,72 @@ package learning.platform.service; import learning.platform.dto.*; +import learning.platform.entity.User; + import java.util.List; +/** + * Servicio para gestionar Quizzes. Aquí se define todo lo que se puede hacer + * con los cuestionarios, como crearlos, obtenerlos, actualizarlos y calificarlos. + */ public interface QuizService { + /** + * Crea un nuevo quiz con sus preguntas. + * @param request El objeto con la información del quiz que se quiere crear. + * @return El quiz que se acaba de crear, con su información. + */ QuizResponse createQuiz(QuizCreateRequest request); + /** + * Busca y obtiene los detalles de un quiz usando su ID. + * @param quizId El número de identificación del quiz que se busca. + * @return El quiz que se encontró. + */ QuizResponse getQuizById(Long quizId); + /** + * Obtiene una lista de todos los quizzes que pertenecen a un curso específico. + * @param courseId El número de identificación del curso. + * @return Una lista de quizzes de ese curso. + */ List getQuizzesByCourse(Long courseId); + /** + * Actualiza la información de un quiz que ya existe. + * @param quizId El número de identificación del quiz a actualizar. + * @param request El objeto con la nueva información. + * @return El quiz actualizado. + */ QuizResponse updateQuiz(Long quizId, QuizCreateRequest request); + /** + * Borra un quiz y todas sus preguntas para siempre. + * @param quizId El número de identificación del quiz que se va a borrar. + */ void deleteQuiz(Long quizId); + /** + * Agrega una nueva pregunta a un quiz que ya existe. + * @param quizId El número de identificación del quiz. + * @param request El objeto con la información de la pregunta a agregar. + * @return El quiz completo, ya con la nueva pregunta. + */ QuizResponse addQuestion(Long quizId, QuestionCreateRequest request); + /** + * Busca un quiz que esté asociado a una lección específica. + * @param lessonId El número de identificación de la lección. + * @return El quiz de esa lección. + */ QuizResponse getQuizByLesson(Long lessonId); - QuizSubmissionResponse submitQuiz(Long quizId, QuizSubmissionRequest request); -} + /** + * Recibe las respuestas de un estudiante a un quiz, lo califica y guarda el resultado. + * @param quizId El número de identificación del quiz que se está enviando. + * @param request El objeto con las respuestas del estudiante. + * @param user El estudiante que está enviando el quiz. + * @return El resultado del quiz, incluyendo la calificación y si lo pasó o no. + */ + QuizSubmissionResponse submitQuiz(Long quizId, QuizSubmissionRequest request, User user); +} \ No newline at end of file diff --git a/src/main/java/learning/platform/service/impl/CourseServiceImpl.java b/src/main/java/learning/platform/service/impl/CourseServiceImpl.java index e55fe08..b7a1898 100644 --- a/src/main/java/learning/platform/service/impl/CourseServiceImpl.java +++ b/src/main/java/learning/platform/service/impl/CourseServiceImpl.java @@ -10,12 +10,13 @@ import learning.platform.repository.CourseRepository; import learning.platform.repository.EnrollmentRepository; import learning.platform.repository.UserRepository; -import jakarta.persistence.EntityNotFoundException; // Para manejar errores +import jakarta.persistence.EntityNotFoundException; import learning.platform.service.CourseService; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.UUID; @Service public class CourseServiceImpl implements CourseService { @@ -68,9 +69,7 @@ public CourseResponseDTO updateCourse(Long courseId, CourseRequestDTO dto, User auditPropagator.propagate(); Course existingCourse = courseRepository.findById(courseId) - .orElseThrow(() -> new EntityNotFoundException("Course not found with id: " + courseId)); - - // Aquí se podría añadir una validación para asegurar que solo el instructor propietario puede editar + .orElseThrow(() -> new EntityNotFoundException("No se encontró el Curso con ese Id: " + courseId)); courseMapper.updateCourseFromDTO(dto, existingCourse); // Si el título cambia, también deberíamos actualizar el slug @@ -93,7 +92,7 @@ public void deleteCourse(Long courseId, User user) { auditPropagator.propagate(); if (!courseRepository.existsById(courseId)) { - throw new EntityNotFoundException("Course not found with id: " + courseId); + throw new EntityNotFoundException("No se encontró el curso con ese Id: " + courseId); } courseRepository.deleteById(courseId); } @@ -106,11 +105,17 @@ private String generateSlug(String title) { .replaceAll("-+", "-"); // Reemplaza múltiples guiones con uno solo } - public Course findCourseById(Long id) { return courseRepository.findById(id).orElse(null); } + @Override + @Transactional(readOnly = true) + public Course findCourseById(Long id) { + return courseRepository.findById(id).orElse(null); + } // Lógica para listar cursos con filtros y paginación + @Override + @Transactional(readOnly = true) public Page findAllPublicCourses(Pageable pageable) { - Page coursePage = courseRepository.findAll(pageable); // Lógica de filtros iría aquí + Page coursePage = courseRepository.findAll(pageable); // Convertimos la página de Entidades a una página de DTOs return coursePage.map(courseMapper::toResponseDTO); } @@ -118,26 +123,24 @@ public Page findAllPublicCourses(Pageable pageable) { /** * Busca un curso por su ID y lo devuelve como DTO. */ + @Override + @Transactional(readOnly = true) public CourseResponseDTO findCourseDtoById(Long id) { return courseRepository.findById(id) .map(courseMapper::toResponseDTO) // Convierte la entidad a DTO si la encuentra - .orElseThrow(() -> new EntityNotFoundException("Course not found with id: " + id)); + .orElseThrow(() -> new EntityNotFoundException("No se encontró el curso con ese Id: " + id)); } - // Lógica para la inscripción - /* + /** + * Método para que solo el admin pueda cambiar el estado de published + */ @Transactional - public Enrollment enrollStudentInCourse(Long courseId, User student) { - if (enrollmentRepository.existsByStudentIdAndCourseId(student.getId(), courseId)) { - throw new IllegalStateException("El estudiante actualmente está inscrito en este curso."); - } - Course course = findCourseById(courseId); - - Enrollment enrollment = new Enrollment(); - enrollment.setStudent(student); - enrollment.setCourse(course); - return enrollmentRepository.save(enrollment); + @Override + public CourseResponseDTO publishCourse(Long courseId, boolean published) { + Course course = courseRepository.findById(courseId) + .orElseThrow(() -> new EntityNotFoundException("No se encontró el Id del Curso: " + courseId)); + course.setPublished(published); + Course updatedCourse = courseRepository.save(course); + return courseMapper.toResponseDTO(updatedCourse); } - - */ } \ No newline at end of file diff --git a/src/main/java/learning/platform/service/impl/QuizServiceImpl.java b/src/main/java/learning/platform/service/impl/QuizServiceImpl.java index e1bb306..7482465 100644 --- a/src/main/java/learning/platform/service/impl/QuizServiceImpl.java +++ b/src/main/java/learning/platform/service/impl/QuizServiceImpl.java @@ -1,111 +1,289 @@ package learning.platform.service.impl; import learning.platform.dto.*; +import learning.platform.entity.*; +import learning.platform.exception.NotFoundException; +import learning.platform.repository.*; import learning.platform.service.QuizService; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @Service public class QuizServiceImpl implements QuizService { + private final QuizRepository quizRepository; + private final QuestionRepository questionRepository; + private final AnswerOptionRepository answerOptionRepository; + private final QuizSubmissionRepository quizSubmissionRepository; + private final AnswerSubmissionRepository answerSubmissionRepository; + private final UserRepository userRepository; + private final CourseRepository courseRepository; + private final LessonRepository lessonRepository; + private final EnrollmentRepository enrollmentRepository; + + public QuizServiceImpl(QuizRepository quizRepository, + QuestionRepository questionRepository, + AnswerOptionRepository answerOptionRepository, + QuizSubmissionRepository quizSubmissionRepository, + AnswerSubmissionRepository answerSubmissionRepository, + UserRepository userRepository, + CourseRepository courseRepository, + LessonRepository lessonRepository, + EnrollmentRepository enrollmentRepository) { + this.quizRepository = quizRepository; + this.questionRepository = questionRepository; + this.answerOptionRepository = answerOptionRepository; + this.quizSubmissionRepository = quizSubmissionRepository; + this.answerSubmissionRepository = answerSubmissionRepository; + this.userRepository = userRepository; + this.courseRepository = courseRepository; + this.lessonRepository = lessonRepository; + this.enrollmentRepository = enrollmentRepository; + } + @Override + @Transactional public QuizResponse createQuiz(QuizCreateRequest request) { - QuizResponse quiz = new QuizResponse(); - quiz.setId(1L); // Simulación: en realidad lo haría la BD + Quiz quiz = new Quiz(); quiz.setTitle(request.getTitle()); quiz.setDescription(request.getDescription()); - quiz.setCourseId(request.getCourseId()); - quiz.setQuestions(new ArrayList<>()); - return quiz; + + if (request.getCourseId() != null) { + Course course = courseRepository.findById(request.getCourseId()) + .orElseThrow(() -> new NotFoundException("No se encontró el id del curso: " + request.getCourseId())); + quiz.setCourse(course); + } + + if (request.getLessonId() != null) { + Lesson lesson = lessonRepository.findById(request.getLessonId()) + .orElseThrow(() -> new NotFoundException("No se encontró el id de la lección: " + request.getLessonId())); + quiz.setLesson(lesson); + } + + Quiz savedQuiz = quizRepository.save(quiz); + + List questions = request.getQuestions().stream() + .map(questionDto -> { + Question question = new Question(); + question.setQuiz(savedQuiz); + question.setText(questionDto.getText()); + question.setPoints(questionDto.getPoints()); + question.setTimeLimitSeconds(questionDto.getTimeLimitSeconds()); + question.setCorrectOptionIndex(questionDto.getCorrectOptionIndex()); + + List options = questionDto.getOptions().stream() + .map(optionDto -> { + AnswerOption option = new AnswerOption(); + option.setText(optionDto.getText()); + option.setQuestion(question); + return option; + }).collect(Collectors.toList()); + + question.setOptions(options); + return question; + }).collect(Collectors.toList()); + + questionRepository.saveAll(questions); + + return convertToQuizResponse(savedQuiz); } @Override public QuizResponse getQuizById(Long quizId) { - // Simulación: en un caso real buscaría en la BD - QuizResponse quiz = new QuizResponse(); - quiz.setId(quizId); - quiz.setTitle("Quiz ejemplo"); - quiz.setDescription("Descripción del quiz"); - quiz.setCourseId(10L); - quiz.setQuestions(new ArrayList<>()); - return quiz; + Quiz quiz = quizRepository.findById(quizId) + .orElseThrow(() -> new NotFoundException("Quiz not found with id: " + quizId)); + return convertToQuizResponse(quiz); } @Override public List getQuizzesByCourse(Long courseId) { - List quizzes = new ArrayList<>(); - quizzes.add(getQuizById(1L)); - return quizzes; + List quizzes = quizRepository.findByCourseId(courseId); + return quizzes.stream() + .map(this::convertToQuizResponse) + .collect(Collectors.toList()); } @Override + @Transactional public QuizResponse updateQuiz(Long quizId, QuizCreateRequest request) { - QuizResponse quiz = getQuizById(quizId); + Quiz quiz = quizRepository.findById(quizId) + .orElseThrow(() -> new NotFoundException("Quiz not found with id: " + quizId)); + quiz.setTitle(request.getTitle()); quiz.setDescription(request.getDescription()); - quiz.setCourseId(request.getCourseId()); - return quiz; + // Lógica para actualizar preguntas, no implementada aquí para simplicidad + + Quiz updatedQuiz = quizRepository.save(quiz); + return convertToQuizResponse(updatedQuiz); } @Override + @Transactional public void deleteQuiz(Long quizId) { - // Simulación: eliminar de la BD + if (!quizRepository.existsById(quizId)) { + throw new NotFoundException("Quiz not found with id: " + quizId); + } + quizRepository.deleteById(quizId); } @Override + @Transactional public QuizResponse addQuestion(Long quizId, QuestionCreateRequest request) { - QuizResponse quiz = getQuizById(quizId); - - if (quiz.getQuestions() == null) { - quiz.setQuestions(new ArrayList<>()); - } + Quiz quiz = quizRepository.findById(quizId) + .orElseThrow(() -> new NotFoundException("Quiz not found with id: " + quizId)); - // Convertimos opciones de creación a opciones de respuesta (DTO) - List optionDTOs = request.getOptions() - .stream() - .map(opt -> new AnswerOptionDTO(null, opt.getText())) // ID null simulado - .collect(Collectors.toList()); - - QuestionResponse question = new QuestionResponse(); - question.setId(100L); // Simulado + Question question = new Question(); + question.setQuiz(quiz); question.setText(request.getText()); - question.setOptions(optionDTOs); + question.setCorrectOptionIndex(request.getCorrectOptionIndex()); question.setPoints(request.getPoints()); question.setTimeLimitSeconds(request.getTimeLimitSeconds()); - quiz.getQuestions().add(question); - return quiz; + List options = request.getOptions().stream() + .map(optionDto -> { + AnswerOption option = new AnswerOption(); + option.setText(optionDto.getText()); + option.setQuestion(question); + return option; + }).collect(Collectors.toList()); + + question.setOptions(options); + questionRepository.save(question); + + return convertToQuizResponse(quiz); } + // Método que faltaba, ahora implementado @Override public QuizResponse getQuizByLesson(Long lessonId) { - QuizResponse quiz = new QuizResponse(); - quiz.setId(200L); - quiz.setTitle("Quiz de la lección " + lessonId); - quiz.setDescription("Preguntas para reforzar la lección"); - quiz.setCourseId(20L); - quiz.setQuestions(new ArrayList<>()); - return quiz; + Quiz quiz = quizRepository.findByLessonId(lessonId) + .orElseThrow(() -> new NotFoundException("Quiz not found for lesson id: " + lessonId)); + return convertToQuizResponse(quiz); } @Override - public QuizSubmissionResponse submitQuiz(Long quizId, QuizSubmissionRequest request) { - QuizResponse quiz = getQuizById(quizId); + @Transactional + public QuizSubmissionResponse submitQuiz(Long quizId, QuizSubmissionRequest request, User user) { + Quiz quiz = quizRepository.findById(quizId) + .orElseThrow(() -> new NotFoundException("Quiz not found with id: " + quizId)); + + if (quiz.getCourse() != null) { + boolean isEnrolled = enrollmentRepository.existsByStudentIdAndCourseId(user.getId(), quiz.getCourse().getId()); + if (!isEnrolled) { + throw new IllegalStateException("User is not enrolled in this course and cannot submit the quiz."); + } + } - int totalQuestions = quiz.getQuestions() != null ? quiz.getQuestions().size() : 0; - int correct = request.getAnswers() != null ? request.getAnswers().size() : 0; // simulado + List questions = questionRepository.findByQuizId(quizId); - QuizSubmissionResponse response = new QuizSubmissionResponse(); - response.setQuizId(quizId); - response.setEnrollmentId(request.getEnrollmentId()); - response.setScore(correct); - response.setPercentage(totalQuestions > 0 ? (correct * 100.0 / totalQuestions) : 0.0); - response.setPassed(response.getPercentage() >= 60.0); + QuizSubmission submission = new QuizSubmission(); + submission.setQuiz(quiz); + submission.setUser(user); - return response; + List submittedAnswers = request.getAnswers().stream() + .map(answerDto -> { + Question question = questionRepository.findById(answerDto.getQuestionId()) + .orElseThrow(() -> new NotFoundException("Question not found with id: " + answerDto.getQuestionId())); + AnswerOption selectedOption = answerOptionRepository.findById(answerDto.getSelectedOptionId()) + .orElseThrow(() -> new NotFoundException("Answer option not found with id: " + answerDto.getSelectedOptionId())); + + AnswerSubmission submissionItem = new AnswerSubmission(); + submissionItem.setQuizSubmission(submission); + submissionItem.setQuestion(question); + submissionItem.setSelectedOption(selectedOption); + return submissionItem; + }).collect(Collectors.toList()); + + int correctCount = 0; + int totalPoints = 0; + for (Question question : questions) { + totalPoints += (question.getPoints() != null) ? question.getPoints() : 0; + Long correctAnswerOptionId = getCorrectAnswerOptionId(question); + + AnswerSubmission studentAnswer = submittedAnswers.stream() + .filter(sa -> sa.getQuestion().getId().equals(question.getId())) + .findFirst() + .orElse(null); + + if (studentAnswer != null && studentAnswer.getSelectedOption() != null) { + if (studentAnswer.getSelectedOption().getId().equals(correctAnswerOptionId)) { + correctCount++; + } + } + } + + submission.setAnswers(submittedAnswers); + + int totalQuestions = questions.size(); + double percentage = (totalQuestions > 0) ? ((double) correctCount / totalQuestions) * 100 : 0.0; + submission.setScore(correctCount); + submission.setPercentage(percentage); + submission.setPassed(percentage >= 60.0); + + quizSubmissionRepository.save(submission); + + return convertToQuizSubmissionResponse(submission); + } + + // Método auxiliar para obtener el ID de la opción de respuesta correcta + private Long getCorrectAnswerOptionId(Question question) { + if (question.getCorrectOptionIndex() != null && question.getCorrectOptionIndex() >= 0 && question.getCorrectOptionIndex() < question.getOptions().size()) { + return question.getOptions().get(question.getCorrectOptionIndex()).getId(); + } + return null; } -} + // Métodos auxiliares para la conversión de entidades a DTOs + private QuizResponse convertToQuizResponse(Quiz quiz) { + QuizResponse dto = new QuizResponse(); + dto.setId(quiz.getId()); + dto.setTitle(quiz.getTitle()); + dto.setDescription(quiz.getDescription()); + dto.setCourseId(quiz.getCourse() != null ? quiz.getCourse().getId() : null); + dto.setLessonId(quiz.getLesson() != null ? quiz.getLesson().getId() : null); + + List questions = questionRepository.findByQuizId(quiz.getId()); + List questionDtos = questions.stream() + .map(this::convertToQuestionResponse) + .collect(Collectors.toList()); + dto.setQuestions(questionDtos); + + return dto; + } + + private QuestionResponse convertToQuestionResponse(Question question) { + QuestionResponse dto = new QuestionResponse(); + dto.setId(question.getId()); + dto.setText(question.getText()); + dto.setPoints(question.getPoints()); + dto.setTimeLimitSeconds(question.getTimeLimitSeconds()); + + List options = answerOptionRepository.findByQuestionId(question.getId()); + List optionDtos = options.stream() + .map(this::convertToAnswerOptionDTO) + .collect(Collectors.toList()); + dto.setOptions(optionDtos); + + return dto; + } + + private AnswerOptionDTO convertToAnswerOptionDTO(AnswerOption option) { + AnswerOptionDTO dto = new AnswerOptionDTO(); + dto.setId(option.getId()); + dto.setText(option.getText()); + return dto; + } + + private QuizSubmissionResponse convertToQuizSubmissionResponse(QuizSubmission submission) { + QuizSubmissionResponse dto = new QuizSubmissionResponse(); + dto.setQuizId(submission.getQuiz().getId()); + dto.setScore(submission.getScore()); + dto.setPercentage(submission.getPercentage()); + dto.setPassed(submission.getPassed()); + return dto; + } +} \ No newline at end of file diff --git a/src/test/java/learning/platform/controller/QuizControllerTest.java b/src/test/java/learning/platform/controller/QuizControllerTest.java index e070612..f40812d 100644 --- a/src/test/java/learning/platform/controller/QuizControllerTest.java +++ b/src/test/java/learning/platform/controller/QuizControllerTest.java @@ -1,70 +1,115 @@ package learning.platform.controller; -import com.fasterxml.jackson.databind.ObjectMapper; -import learning.platform.dto.AnswerSubmissionRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import learning.platform.dto.QuizCreateRequest; +import learning.platform.dto.QuizResponse; import learning.platform.dto.QuizSubmissionRequest; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; +import learning.platform.dto.QuizSubmissionResponse; +import learning.platform.entity.User; +import learning.platform.service.QuizService; +import org.springframework.http.HttpStatus; +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 java.util.Collections; +import java.util.List; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +@RestController +@RequestMapping("/api/quizzes") +@Tag(name = "Quizzes", description = "Gestión de evaluaciones (quiz) asociadas a cursos y lecciones") +public class QuizControllerTest { -@SpringBootTest -@AutoConfigureMockMvc -class QuizControllerTest { + private final QuizService quizService; - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @Test - void shouldCreateQuizSuccessfully() throws Exception { - QuizCreateRequest request = new QuizCreateRequest(); - request.setTitle("Quiz de prueba"); - request.setDescription("Descripción de prueba"); - request.setCourseId(1L); - - mockMvc.perform(post("/api/quizzes") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.title").value("Quiz de prueba")) - .andExpect(jsonPath("$.courseId").value(1)); + public QuizControllerTest(QuizService quizService) { + this.quizService = quizService; } - @Test - void shouldGetQuizById() throws Exception { - mockMvc.perform(get("/api/quizzes/1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(1)); + @Operation( + summary = "Crear un nuevo quiz", + description = "Solo los instructores pueden crear quizzes asociados a cursos o lecciones.", + responses = { + @ApiResponse(responseCode = "201", description = "Quiz creado", + content = @Content(schema = @Schema(implementation = QuizResponse.class))), + @ApiResponse(responseCode = "400", description = "Datos inválidos"), + @ApiResponse(responseCode = "403", description = "Acceso denegado") + } + ) + @PreAuthorize("hasRole('INSTRUCTOR')") + @PostMapping + public ResponseEntity createQuiz( + @Valid @RequestBody QuizCreateRequest request) { + QuizResponse response = quizService.createQuiz(request); + return ResponseEntity.status(201).body(response); } - @Test - void shouldSubmitQuizAnswers() throws Exception { - QuizSubmissionRequest request = new QuizSubmissionRequest(); - request.setEnrollmentId(10L); + @Operation( + summary = "Obtener un quiz por ID", + description = "Devuelve la información del quiz con preguntas y respuestas." + ) + @GetMapping("/{quizId}") + public ResponseEntity getQuizById( + @Parameter(description = "ID del quiz") @PathVariable Long quizId) { + QuizResponse response = quizService.getQuizById(quizId); + return ResponseEntity.ok(response); + } - AnswerSubmissionRequest answer = new AnswerSubmissionRequest(); - answer.setQuestionId(987L); - answer.setSelectedOptionId(1234L); + @Operation( + summary = "Obtener quizzes de un curso", + description = "Devuelve todos los quizzes asociados a un curso específico." + ) + @GetMapping("/course/{courseId}") + public ResponseEntity> getQuizzesByCourse( + @Parameter(description = "ID del curso") @PathVariable Long courseId) { + List responses = quizService.getQuizzesByCourse(courseId); + return ResponseEntity.ok(responses); + } - request.setAnswers(Collections.singletonList(answer)); + @Operation( + summary = "Obtener quiz de una lección", + description = "Devuelve el quiz asignado a una lección." + ) + @GetMapping("/lesson/{lessonId}") + public ResponseEntity getQuizByLesson( + @Parameter(description = "ID de la lección") @PathVariable Long lessonId) { + QuizResponse response = quizService.getQuizByLesson(lessonId); + return ResponseEntity.ok(response); + } - mockMvc.perform(post("/api/quizzes/1/submit") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.quizId").value(1)) - .andExpect(jsonPath("$.enrollmentId").value(10)); + @Operation( + summary = "Enviar respuestas de un quiz", + description = "Un estudiante envía sus respuestas a un quiz y recibe puntaje.", + responses = { + @ApiResponse(responseCode = "200", description = "Respuestas procesadas", + content = @Content(schema = @Schema(implementation = QuizSubmissionResponse.class))), + @ApiResponse(responseCode = "400", description = "Respuestas inválidas") + } + ) + @PostMapping("/{quizId}/submit") + public ResponseEntity submitQuiz( + @Parameter(description = "ID del quiz a resolver") @PathVariable Long quizId, + @Valid @RequestBody QuizSubmissionRequest request, + @AuthenticationPrincipal User student) { + QuizSubmissionResponse response = quizService.submitQuiz(quizId, request, student); + return ResponseEntity.ok(response); } -} + @Operation( + summary = "Eliminar un quiz", + description = "Solo instructores pueden eliminar quizzes." + ) + @PreAuthorize("hasRole('INSTRUCTOR')") + @DeleteMapping("/{quizId}") + public ResponseEntity deleteQuiz( + @Parameter(description = "ID del quiz a eliminar") @PathVariable Long quizId) { + quizService.deleteQuiz(quizId); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/src/test/java/learning/platform/service/CourseServiceImplTest.java b/src/test/java/learning/platform/service/CourseServiceImplTest.java index 57e1ac3..a42fbff 100644 --- a/src/test/java/learning/platform/service/CourseServiceImplTest.java +++ b/src/test/java/learning/platform/service/CourseServiceImplTest.java @@ -12,6 +12,11 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import learning.platform.helper.AuditContext; +import learning.platform.helper.AuditPropagator; +import learning.platform.repository.EnrollmentRepository; +import learning.platform.repository.UserRepository; +import learning.platform.service.impl.CourseServiceImpl; import java.util.Optional; @@ -28,6 +33,18 @@ class CourseServiceImplTest { @Mock private CourseMapper courseMapper; + @Mock + private EnrollmentRepository enrollmentRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private AuditContext auditContext; + + @Mock + private AuditPropagator auditPropagator; + @InjectMocks private CourseServiceImpl courseServiceImpl; @@ -90,33 +107,52 @@ void testUpdateCourse() { CourseResponseDTO responseDTO = new CourseResponseDTO(); responseDTO.setTitle("Updated Title"); + // Creamos un usuario de prueba para el test + User user = new User(); + user.setId(1L); + when(courseRepository.findById(courseId)).thenReturn(Optional.of(existingCourse)); when(courseRepository.save(any(Course.class))).thenReturn(existingCourse); when(courseMapper.toResponseDTO(any(Course.class))).thenReturn(responseDTO); // Act - CourseResponseDTO result = courseServiceImpl.updateCourse(courseId, requestDTO); + // Pasamos el usuario como segundo parámetro + CourseResponseDTO result = courseServiceImpl.updateCourse(courseId, requestDTO, user); // Assert assertNotNull(result); - assertEquals("Updated Title", result.getTitle()); + assertEquals("Título Actualizado", result.getTitle()); verify(courseRepository, times(1)).findById(courseId); verify(courseRepository, times(1)).save(any(Course.class)); + // Verificamos que se llamen a los métodos de auditoría + verify(auditContext, times(1)).setCurrentUser(anyString()); + verify(auditPropagator, times(1)).propagate(); } @Test void testDeleteCourse() { // Arrange Long courseId = 1L; + + // Creamos un usuario de prueba para el test + User user = new User(); + user.setId(1L); + when(courseRepository.existsById(courseId)).thenReturn(true); - // Como deleteById no devuelve nada, usamos doNothing() doNothing().when(courseRepository).deleteById(courseId); + // Verificamos que los métodos de auditoría se llamen correctamente + doNothing().when(auditContext).setCurrentUser(anyString()); + doNothing().when(auditPropagator).propagate(); + // Act - courseServiceImpl.deleteCourse(courseId); + // Pasamos el usuario como segundo parámetro + courseServiceImpl.deleteCourse(courseId, user); // Assert - // Verificamos que el método deleteById fue llamado exactamente una vez con el ID correcto verify(courseRepository, times(1)).deleteById(courseId); + // Verificamos que se llamen a los métodos de auditoría + verify(auditContext, times(1)).setCurrentUser(anyString()); + verify(auditPropagator, times(1)).propagate(); } } \ No newline at end of file diff --git a/src/test/java/learning/platform/service/impl/QuizServiceImplTest.java b/src/test/java/learning/platform/service/impl/QuizServiceImplTest.java new file mode 100644 index 0000000..9d28a5e --- /dev/null +++ b/src/test/java/learning/platform/service/impl/QuizServiceImplTest.java @@ -0,0 +1,257 @@ +package learning.platform.service.impl; + +import learning.platform.dto.*; +import learning.platform.entity.*; +import learning.platform.exception.NotFoundException; +import learning.platform.repository.*; +import learning.platform.service.QuizService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Unit Tests for QuizServiceImpl") +public class QuizServiceImplTest { + + @Mock + private QuizRepository quizRepository; + @Mock + private QuestionRepository questionRepository; + @Mock + private AnswerOptionRepository answerOptionRepository; + @Mock + private QuizSubmissionRepository quizSubmissionRepository; + @Mock + private UserRepository userRepository; + @Mock + private CourseRepository courseRepository; + @Mock + private LessonRepository lessonRepository; + @Mock + private EnrollmentRepository enrollmentRepository; + + @InjectMocks + private QuizServiceImpl quizService; + + // Entidades de prueba + private Quiz quiz; + private User studentUser; + private Course course; + private Enrollment enrollment; + + @BeforeEach + void setUp() { + // Configuración de las entidades base para las pruebas + course = new Course(); + course.setId(1L); + + quiz = new Quiz(); + quiz.setId(10L); + quiz.setTitle("Test Quiz"); + quiz.setDescription("A basic quiz for testing."); + quiz.setCourse(course); + + studentUser = new User(); + studentUser.setId(1L); + studentUser.setFullName("John Doe"); + + enrollment = new Enrollment(); + enrollment.setId(100L); + enrollment.setStudent(studentUser); + enrollment.setCourse(course); + } + + // --- Pruebas para createQuiz --- + @Test + @DisplayName("Should create a quiz successfully") + void shouldCreateQuizSuccessfully() { + // Simula el comportamiento del repositorio al guardar + when(courseRepository.findById(any(Long.class))).thenReturn(Optional.of(course)); + when(quizRepository.save(any(Quiz.class))).thenReturn(quiz); + when(questionRepository.saveAll(any())).thenReturn(Collections.emptyList()); + + // Crea el DTO de solicitud + QuizCreateRequest request = new QuizCreateRequest(); + request.setTitle("Test Quiz"); + request.setDescription("A basic quiz."); + request.setCourseId(1L); + request.setQuestions(new ArrayList<>()); // Preguntas simuladas + + QuizResponse response = quizService.createQuiz(request); + + assertNotNull(response); + assertEquals(quiz.getId(), response.getId()); + assertEquals(quiz.getTitle(), response.getTitle()); + } + + @Test + @DisplayName("Should throw NotFoundException when creating quiz with non-existent course") + void shouldThrowNotFoundExceptionForNonExistentCourse() { + when(courseRepository.findById(any(Long.class))).thenReturn(Optional.empty()); + + QuizCreateRequest request = new QuizCreateRequest(); + request.setTitle("Test Quiz"); + request.setCourseId(99L); // ID que no existe + + assertThrows(NotFoundException.class, () -> quizService.createQuiz(request)); + } + + // --- Pruebas para getQuizById --- + @Test + @DisplayName("Should retrieve a quiz by ID") + void shouldGetQuizByIdSuccessfully() { + when(quizRepository.findById(10L)).thenReturn(Optional.of(quiz)); + when(questionRepository.findByQuizId(10L)).thenReturn(Collections.emptyList()); + + QuizResponse response = quizService.getQuizById(10L); + + assertNotNull(response); + assertEquals(quiz.getId(), response.getId()); + } + + @Test + @DisplayName("Should throw NotFoundException when quiz ID does not exist") + void shouldThrowNotFoundExceptionWhenQuizNotFound() { + when(quizRepository.findById(any(Long.class))).thenReturn(Optional.empty()); + + assertThrows(NotFoundException.class, () -> quizService.getQuizById(99L)); + } + + // --- Pruebas para submitQuiz --- + @Test + @DisplayName("Should submit a quiz and return a passing score") + void shouldSubmitQuizAndReturnPassingScore() { + // Mock de datos para la prueba + Question q1 = new Question(); + q1.setId(1L); + q1.setText("Q1"); + q1.setPoints(10); + q1.setCorrectOptionIndex(0); + + AnswerOption correctOption = new AnswerOption(); + correctOption.setId(101L); + correctOption.setText("Correct"); + correctOption.setQuestion(q1); + + AnswerOption incorrectOption = new AnswerOption(); + incorrectOption.setId(102L); + incorrectOption.setText("Incorrect"); + incorrectOption.setQuestion(q1); + + List options = List.of(correctOption, incorrectOption); + q1.setOptions(options); + + // Simula el comportamiento del repositorio + when(quizRepository.findById(any(Long.class))).thenReturn(Optional.of(quiz)); + when(enrollmentRepository.existsByStudentIdAndCourseId(any(Long.class), any(Long.class))).thenReturn(true); + when(questionRepository.findByQuizId(any(Long.class))).thenReturn(List.of(q1)); + when(questionRepository.findById(q1.getId())).thenReturn(Optional.of(q1)); + when(answerOptionRepository.findById(correctOption.getId())).thenReturn(Optional.of(correctOption)); + when(quizSubmissionRepository.save(any(QuizSubmission.class))).thenAnswer(i -> i.getArguments()[0]); + + // Crea el DTO de solicitud de envío con la respuesta correcta + QuizSubmissionRequest request = new QuizSubmissionRequest(); + AnswerSubmissionItem item = new AnswerSubmissionItem(); + item.setQuestionId(q1.getId()); + item.setSelectedOptionId(correctOption.getId()); + request.setAnswers(List.of(item)); + + QuizSubmissionResponse response = quizService.submitQuiz(quiz.getId(), request, studentUser); + + assertNotNull(response); + assertTrue(response.getPassed()); + assertEquals(100.0, response.getPercentage()); + assertEquals(1, response.getScore()); + } + + @Test + @DisplayName("Should submit a quiz and return a failing score") + void shouldSubmitQuizAndReturnFailingScore() { + // Mock de datos para la prueba + Question q1 = new Question(); + q1.setId(1L); + q1.setText("Q1"); + q1.setPoints(10); + q1.setCorrectOptionIndex(0); + + AnswerOption correctOption = new AnswerOption(); + correctOption.setId(101L); + correctOption.setText("Correct"); + correctOption.setQuestion(q1); + + AnswerOption incorrectOption = new AnswerOption(); + incorrectOption.setId(102L); + incorrectOption.setText("Incorrect"); + incorrectOption.setQuestion(q1); + + List options = List.of(correctOption, incorrectOption); + q1.setOptions(options); + + // Simula el comportamiento del repositorio + when(quizRepository.findById(any(Long.class))).thenReturn(Optional.of(quiz)); + when(enrollmentRepository.existsByStudentIdAndCourseId(any(Long.class), any(Long.class))).thenReturn(true); + when(questionRepository.findByQuizId(any(Long.class))).thenReturn(List.of(q1)); + when(questionRepository.findById(q1.getId())).thenReturn(Optional.of(q1)); + when(answerOptionRepository.findById(incorrectOption.getId())).thenReturn(Optional.of(incorrectOption)); + when(quizSubmissionRepository.save(any(QuizSubmission.class))).thenAnswer(i -> i.getArguments()[0]); + + // Crea el DTO de solicitud de envío con la respuesta INCORRECTA + QuizSubmissionRequest request = new QuizSubmissionRequest(); + AnswerSubmissionItem item = new AnswerSubmissionItem(); + item.setQuestionId(q1.getId()); + item.setSelectedOptionId(incorrectOption.getId()); + request.setAnswers(List.of(item)); + + QuizSubmissionResponse response = quizService.submitQuiz(quiz.getId(), request, studentUser); + + assertNotNull(response); + assertFalse(response.getPassed()); + assertEquals(0.0, response.getPercentage()); + assertEquals(0, response.getScore()); + } + + @Test + @DisplayName("Should throw IllegalStateException if student is not enrolled in the course") + void shouldThrowExceptionIfStudentNotEnrolled() { + when(quizRepository.findById(any(Long.class))).thenReturn(Optional.of(quiz)); + when(enrollmentRepository.existsByStudentIdAndCourseId(any(Long.class), any(Long.class))).thenReturn(false); + + QuizSubmissionRequest request = new QuizSubmissionRequest(); + + assertThrows(IllegalStateException.class, () -> quizService.submitQuiz(quiz.getId(), request, studentUser)); + } + + // --- Pruebas para deleteQuiz --- + @Test + @DisplayName("Should delete a quiz successfully") + void shouldDeleteQuizSuccessfully() { + when(quizRepository.existsById(10L)).thenReturn(true); + + quizService.deleteQuiz(10L); + + Mockito.verify(quizRepository, Mockito.times(1)).deleteById(10L); + } + + @Test + @DisplayName("Should throw NotFoundException when deleting non-existent quiz") + void shouldThrowNotFoundExceptionForNonExistentQuiz() { + when(quizRepository.existsById(any(Long.class))).thenReturn(false); + + assertThrows(NotFoundException.class, () -> quizService.deleteQuiz(99L)); + } +} \ No newline at end of file