diff --git a/PHASE5_COMPLETE.md b/PHASE5_COMPLETE.md new file mode 100644 index 0000000..bea38a9 --- /dev/null +++ b/PHASE5_COMPLETE.md @@ -0,0 +1,366 @@ +# ✅ Fase 5: Assessments & Assignments - COMPLETA + +## Resumen + +Se ha completado exitosamente la **Fase 5: Assessments & Assignments** del proyecto SUPAP Backend. Esta fase incluye la implementación completa del sistema de tareas (assignments), entregas (submissions), evaluaciones (assessments) e intentos (attempts) para el módulo Aula Virtual. + +**Fecha de finalización**: 2025-11-24 +**Estado**: ✅ Completada + +--- + +## 🎯 Componentes Implementados + +### 1. Entidades JPA ✅ + +#### Assignment Entity +- ✅ Relación Many-to-One con Lesson (opcional) +- ✅ Campos: title, instructions, dueDate, maxScore +- ✅ Relación One-to-Many con Submission + +#### Submission Entity +- ✅ Relación Many-to-One con Assignment y User +- ✅ Enum SubmissionStatus: DRAFT, SUBMITTED, GRADED, RETURNED +- ✅ Campos: content, fileUrl, score, feedback +- ✅ Tracking de calificación: gradedAt, gradedBy + +#### Assessment Entity +- ✅ Relación Many-to-One con Lesson (opcional) +- ✅ Campos: title, questions (JSON), timeLimit, passingScore, maxAttempts +- ✅ Relación One-to-Many con AssessmentAttempt + +#### AssessmentAttempt Entity +- ✅ Relación Many-to-One con Assessment y User +- ✅ Campos: answers (JSON), score, passed, attemptNumber +- ✅ Tracking: startedAt, completedAt + +### 2. Repositorios ✅ + +#### AssignmentRepository +- ✅ `findByLessonId()` - Asignaciones por lección + +#### SubmissionRepository +- ✅ `findByAssignmentIdAndUserId()` - Entrega específica +- ✅ `findByAssignmentId()` - Todas las entregas de una asignación +- ✅ `findByUserId()` - Entregas de un usuario +- ✅ `findByAssignmentIdAndStatus()` - Filtrar por estado + +#### AssessmentRepository +- ✅ `findByLessonId()` - Evaluaciones por lección + +#### AssessmentAttemptRepository +- ✅ `findByAssessmentIdAndUserId()` - Intentos de un usuario +- ✅ `findByAssessmentId()` - Todos los intentos +- ✅ `countByAssessmentIdAndUserId()` - Contar intentos +- ✅ `findLatestAttemptByAssessmentIdAndUserId()` - Último intento + +### 3. Migraciones Flyway ✅ + +- ✅ `V6__create_assignments_assessments_tables.sql` - Todas las tablas + - Tabla `assignments` con relación a lessons + - Tabla `submissions` con relación a assignments y users + - Tabla `assessments` con relación a lessons + - Tabla `assessment_attempts` con relación a assessments y users + - Índices para optimización + +### 4. DTOs ✅ + +#### Request DTOs: +- ✅ `AssignmentRequest` - Crear/actualizar asignaciones +- ✅ `SubmissionRequest` - Crear/actualizar entregas +- ✅ `GradeSubmissionRequest` - Calificar entregas +- ✅ `AssessmentRequest` - Crear/actualizar evaluaciones +- ✅ `AssessmentAttemptRequest` - Iniciar/enviar intentos + +#### Response DTOs: +- ✅ `AssignmentResponse` - Información de asignación +- ✅ `SubmissionResponse` - Información de entrega (con datos de calificación) +- ✅ `AssessmentResponse` - Información de evaluación +- ✅ `AssessmentAttemptResponse` - Información de intento + +### 5. Servicios ✅ + +#### AssignmentService +- ✅ `getAssignmentsByLesson()` - Listar asignaciones por lección +- ✅ `getAssignmentById()` - Obtener asignación +- ✅ `createAssignment()` - Crear asignación +- ✅ `updateAssignment()` - Actualizar asignación +- ✅ `deleteAssignment()` - Eliminar asignación + +#### SubmissionService +- ✅ `createOrUpdateSubmission()` - Crear o actualizar entrega + - Crea nueva si no existe, actualiza si ya existe +- ✅ `submitAssignment()` - Enviar entrega (cambia estado a SUBMITTED) +- ✅ `gradeSubmission()` - Calificar entrega (Instructor/Admin) + - Actualiza score, feedback, estado a GRADED + - Registra quién calificó y cuándo +- ✅ `getMySubmissions()` - Mis entregas +- ✅ `getSubmissionById()` - Obtener entrega (con validación de propiedad) +- ✅ `getSubmissionsByAssignment()` - Entregas de una asignación (Instructor/Admin) +- ✅ `getSubmissionsByAssignmentAndStatus()` - Filtrar por estado + +#### AssessmentService +- ✅ `getAssessmentsByLesson()` - Listar evaluaciones por lección +- ✅ `getAssessmentById()` - Obtener evaluación +- ✅ `createAssessment()` - Crear evaluación +- ✅ `updateAssessment()` - Actualizar evaluación +- ✅ `deleteAssessment()` - Eliminar evaluación +- ✅ `startAttempt()` - Iniciar intento + - Validación de máximo de intentos + - Asignación automática de attemptNumber +- ✅ `submitAttempt()` - Enviar intento + - Cálculo de score (placeholder - requiere implementación de lógica de scoring) + - Determinación de passed basado en passingScore +- ✅ `getMyAttempts()` - Mis intentos +- ✅ `getAssessmentAttempts()` - Todos los intentos (Instructor/Admin) + +### 6. Controladores ✅ + +#### AssignmentController +- ✅ `GET /api/v1/assignments/lesson/{lessonId}` - Asignaciones por lección +- ✅ `GET /api/v1/assignments/{id}` - Obtener asignación +- ✅ `POST /api/v1/assignments` - Crear asignación (Admin/Instructor) +- ✅ `PUT /api/v1/assignments/{id}` - Actualizar asignación (Admin/Instructor) +- ✅ `DELETE /api/v1/assignments/{id}` - Eliminar asignación (Admin/Instructor) +- ✅ `POST /api/v1/assignments/submissions` - Crear/actualizar entrega (Usuario) +- ✅ `POST /api/v1/assignments/submissions/{id}/submit` - Enviar entrega (Usuario) +- ✅ `GET /api/v1/assignments/submissions/my` - Mis entregas (Usuario) +- ✅ `GET /api/v1/assignments/submissions/{id}` - Obtener entrega (Usuario) +- ✅ `GET /api/v1/assignments/{assignmentId}/submissions` - Entregas de asignación (Admin/Instructor) +- ✅ `POST /api/v1/assignments/submissions/{id}/grade` - Calificar entrega (Admin/Instructor) + +#### AssessmentController +- ✅ `GET /api/v1/assessments/lesson/{lessonId}` - Evaluaciones por lección +- ✅ `GET /api/v1/assessments/{id}` - Obtener evaluación +- ✅ `POST /api/v1/assessments` - Crear evaluación (Admin/Instructor) +- ✅ `PUT /api/v1/assessments/{id}` - Actualizar evaluación (Admin/Instructor) +- ✅ `DELETE /api/v1/assessments/{id}` - Eliminar evaluación (Admin/Instructor) +- ✅ `POST /api/v1/assessments/attempts/start` - Iniciar intento (Usuario) +- ✅ `POST /api/v1/assessments/attempts/{id}/submit` - Enviar intento (Usuario) +- ✅ `GET /api/v1/assessments/{assessmentId}/attempts/my` - Mis intentos (Usuario) +- ✅ `GET /api/v1/assessments/{assessmentId}/attempts` - Todos los intentos (Admin/Instructor) + +### 7. Excepciones ✅ + +- ✅ `ResourceNotFoundException` - Recurso no encontrado +- ✅ `EventRegistrationException` - Reutilizada para errores de autorización +- ✅ Manejo en `GlobalExceptionHandler` + +### 8. Seguridad ✅ + +- ✅ Endpoints públicos para lectura de assignments y assessments +- ✅ Endpoints protegidos para estudiantes (requiere autenticación) +- ✅ Endpoints protegidos para instructores (requiere ROLE_INSTRUCTOR o ROLE_ADMIN) +- ✅ Validación de propiedad de recursos (solo el dueño puede acceder) + +--- + +## 📊 Funcionalidades Implementadas + +### Assignments (Tareas) +- ✅ Crear asignaciones asociadas a lecciones +- ✅ Actualizar y eliminar asignaciones +- ✅ Fecha de vencimiento y puntaje máximo +- ✅ Instrucciones detalladas + +### Submissions (Entregas) +- ✅ Crear o actualizar entregas (borrador) +- ✅ Enviar entregas (cambia estado a SUBMITTED) +- ✅ Calificar entregas (Instructor/Admin) +- ✅ Feedback y puntaje +- ✅ Tracking de quién calificó y cuándo +- ✅ Estados: DRAFT, SUBMITTED, GRADED, RETURNED + +### Assessments (Evaluaciones/Quizzes) +- ✅ Crear evaluaciones con preguntas (JSON) +- ✅ Límite de tiempo +- ✅ Puntaje de aprobación +- ✅ Límite de intentos máximo +- ✅ Actualizar y eliminar evaluaciones + +### Assessment Attempts (Intentos) +- ✅ Iniciar intentos de evaluación +- ✅ Validación de máximo de intentos +- ✅ Enviar respuestas (JSON) +- ✅ Cálculo de score y estado passed +- ✅ Tracking de intentos (attemptNumber) +- ✅ Historial de intentos + +--- + +## 🚀 Endpoints Disponibles + +### Públicos +- `GET /api/v1/assignments/lesson/{lessonId}` - Asignaciones por lección +- `GET /api/v1/assignments/{id}` - Obtener asignación +- `GET /api/v1/assessments/lesson/{lessonId}` - Evaluaciones por lección +- `GET /api/v1/assessments/{id}` - Obtener evaluación + +### Autenticados (Usuario) +- `POST /api/v1/assignments/submissions` - Crear/actualizar entrega +- `POST /api/v1/assignments/submissions/{id}/submit` - Enviar entrega +- `GET /api/v1/assignments/submissions/my` - Mis entregas +- `GET /api/v1/assignments/submissions/{id}` - Obtener entrega +- `POST /api/v1/assessments/attempts/start` - Iniciar intento +- `POST /api/v1/assessments/attempts/{id}/submit` - Enviar intento +- `GET /api/v1/assessments/{assessmentId}/attempts/my` - Mis intentos + +### Admin/Instructor +- `POST /api/v1/assignments` - Crear asignación +- `PUT /api/v1/assignments/{id}` - Actualizar asignación +- `DELETE /api/v1/assignments/{id}` - Eliminar asignación +- `GET /api/v1/assignments/{assignmentId}/submissions` - Entregas de asignación +- `POST /api/v1/assignments/submissions/{id}/grade` - Calificar entrega +- `POST /api/v1/assessments` - Crear evaluación +- `PUT /api/v1/assessments/{id}` - Actualizar evaluación +- `DELETE /api/v1/assessments/{id}` - Eliminar evaluación +- `GET /api/v1/assessments/{assessmentId}/attempts` - Todos los intentos + +--- + +## 📝 Ejemplos de Uso + +### Crear Asignación (Instructor) +```bash +curl -X POST http://localhost:8080/api/v1/assignments \ + -H "Authorization: Bearer INSTRUCTOR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lessonId": 5, + "title": "Tarea: Análisis de caso clínico", + "instructions": "Analizar el caso presentado y responder las preguntas...", + "dueDate": "2025-12-20T23:59:59", + "maxScore": 100 + }' +``` + +### Crear Entrega (Estudiante) +```bash +curl -X POST http://localhost:8080/api/v1/assignments/submissions \ + -H "Authorization: Bearer USER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "assignmentId": 1, + "content": "Mi análisis del caso...", + "fileUrl": "https://storage.example.com/submission.pdf" + }' +``` + +### Enviar Entrega +```bash +curl -X POST http://localhost:8080/api/v1/assignments/submissions/1/submit \ + -H "Authorization: Bearer USER_TOKEN" +``` + +### Calificar Entrega (Instructor) +```bash +curl -X POST http://localhost:8080/api/v1/assignments/submissions/1/grade \ + -H "Authorization: Bearer INSTRUCTOR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "score": 85, + "feedback": "Excelente análisis. Considera profundizar en..." + }' +``` + +### Crear Evaluación (Instructor) +```bash +curl -X POST http://localhost:8080/api/v1/assessments \ + -H "Authorization: Bearer INSTRUCTOR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lessonId": 5, + "title": "Quiz: Fundamentos de Psicoterapias", + "questions": "{\"questions\": [...]}", + "timeLimit": 30, + "passingScore": 70, + "maxAttempts": 3 + }' +``` + +### Iniciar Intento de Evaluación +```bash +curl -X POST http://localhost:8080/api/v1/assessments/attempts/start \ + -H "Authorization: Bearer USER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "assessmentId": 1 + }' +``` + +### Enviar Intento +```bash +curl -X POST http://localhost:8080/api/v1/assessments/attempts/1/submit \ + -H "Authorization: Bearer USER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "assessmentId": 1, + "answers": "{\"q1\": \"answer1\", \"q2\": \"answer2\"}" + }' +``` + +--- + +## ✅ Checklist de Fase 5 + +- [x] Assignment entity & submission +- [x] Assessment/Quiz entity +- [x] Grading system +- [x] Validación de intentos máximos +- [x] Tracking de calificaciones +- [x] Estados de entregas +- [x] Cálculo de score (placeholder) + +--- + +## 📝 Próximos Pasos (Fase 6) + +La siguiente fase incluirá: +- [ ] Payment entity completo +- [ ] MercadoPago integration +- [ ] Payment webhooks +- [ ] Receipt generation +- [ ] Refund handling + +--- + +## 🔧 Notas Técnicas + +### Características Implementadas +1. **Estados de Entrega**: DRAFT → SUBMITTED → GRADED → RETURNED +2. **Validación de Intentos**: Control de máximo de intentos por evaluación +3. **Tracking de Calificaciones**: Registro de quién calificó y cuándo +4. **JSON para Preguntas/Respuestas**: Estructura flexible para evaluaciones +5. **Borradores**: Los estudiantes pueden guardar borradores antes de enviar + +### Consideraciones +- El cálculo de score en assessments es un placeholder - requiere implementación de lógica de scoring real +- Las preguntas y respuestas se almacenan como JSON - requiere validación y parsing en el frontend +- Los archivos de entrega se almacenan como URLs - requiere integración con servicio de almacenamiento (S3, Cloudinary) +- Los instructores pueden ver todas las entregas de sus asignaciones +- Los estudiantes solo pueden ver sus propias entregas + +### Pendiente para Producción +- Implementar lógica real de scoring para assessments +- Integración con servicio de almacenamiento de archivos +- Validación y parsing de JSON de preguntas/respuestas +- Notificaciones por email al calificar entregas +- Sistema de comentarios en entregas + +--- + +## 📚 Documentación + +- **Arquitectura**: `BACKEND_ARCHITECTURE_GUIDE.md` +- **Fase 1**: `PHASE1_COMPLETE.md` +- **Fase 2**: `PHASE2_COMPLETE.md` +- **Fase 3**: `PHASE3_COMPLETE.md` +- **Fase 4**: `PHASE4_COMPLETE.md` +- **API Docs**: Swagger UI en `/swagger-ui.html` + +--- + +**Fase 5 Completada** ✅ +**Fecha**: 2025-11-24 +**Próxima Fase**: Fase 6 - Payments Integration + diff --git a/src/main/java/uy/supap/controller/AssessmentController.java b/src/main/java/uy/supap/controller/AssessmentController.java new file mode 100644 index 0000000..98b068f --- /dev/null +++ b/src/main/java/uy/supap/controller/AssessmentController.java @@ -0,0 +1,104 @@ +package uy.supap.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import uy.supap.model.dto.request.AssessmentAttemptRequest; +import uy.supap.model.dto.request.AssessmentRequest; +import uy.supap.model.dto.response.AssessmentAttemptResponse; +import uy.supap.model.dto.response.AssessmentResponse; +import uy.supap.service.AssessmentService; + +import java.util.List; + +/** + * Assessment controller. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/assessments") +@RequiredArgsConstructor +@Tag(name = "Assessments", description = "Assessment management endpoints") +public class AssessmentController { + + private final AssessmentService assessmentService; + + @GetMapping("/lesson/{lessonId}") + @Operation(summary = "Get assessments by lesson", description = "Returns all assessments for a lesson") + public ResponseEntity> getAssessmentsByLesson(@PathVariable Long lessonId) { + return ResponseEntity.ok(assessmentService.getAssessmentsByLesson(lessonId)); + } + + @GetMapping("/{id}") + @Operation(summary = "Get assessment by ID", description = "Returns assessment details") + public ResponseEntity getAssessmentById(@PathVariable Long id) { + return ResponseEntity.ok(assessmentService.getAssessmentById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'INSTRUCTOR')") + @Operation(summary = "Create assessment", description = "Creates a new assessment (Admin/Instructor)") + public ResponseEntity createAssessment(@Valid @RequestBody AssessmentRequest request) { + log.info("Creating assessment: {}", request.getTitle()); + AssessmentResponse assessment = assessmentService.createAssessment(request); + return ResponseEntity.status(HttpStatus.CREATED).body(assessment); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'INSTRUCTOR')") + @Operation(summary = "Update assessment", description = "Updates an existing assessment (Admin/Instructor)") + public ResponseEntity updateAssessment( + @PathVariable Long id, + @Valid @RequestBody AssessmentRequest request) { + log.info("Updating assessment: {}", id); + return ResponseEntity.ok(assessmentService.updateAssessment(id, request)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'INSTRUCTOR')") + @Operation(summary = "Delete assessment", description = "Deletes an assessment (Admin/Instructor)") + public ResponseEntity deleteAssessment(@PathVariable Long id) { + log.info("Deleting assessment: {}", id); + assessmentService.deleteAssessment(id); + return ResponseEntity.noContent().build(); + } + + // Assessment attempt endpoints + @PostMapping("/attempts/start") + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Start assessment attempt", description = "Starts a new assessment attempt (User)") + public ResponseEntity startAttempt(@Valid @RequestBody AssessmentAttemptRequest request) { + AssessmentAttemptResponse attempt = assessmentService.startAttempt(request); + return ResponseEntity.status(HttpStatus.CREATED).body(attempt); + } + + @PostMapping("/attempts/{id}/submit") + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Submit assessment attempt", description = "Submits an assessment attempt (User)") + public ResponseEntity submitAttempt( + @PathVariable Long id, + @Valid @RequestBody AssessmentAttemptRequest request) { + return ResponseEntity.ok(assessmentService.submitAttempt(id, request)); + } + + @GetMapping("/{assessmentId}/attempts/my") + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Get my attempts", description = "Returns current user's attempts for an assessment") + public ResponseEntity> getMyAttempts(@PathVariable Long assessmentId) { + return ResponseEntity.ok(assessmentService.getMyAttempts(assessmentId)); + } + + @GetMapping("/{assessmentId}/attempts") + @PreAuthorize("hasAnyRole('ADMIN', 'INSTRUCTOR')") + @Operation(summary = "Get all attempts", description = "Returns all attempts for an assessment (Admin/Instructor)") + public ResponseEntity> getAssessmentAttempts(@PathVariable Long assessmentId) { + return ResponseEntity.ok(assessmentService.getAssessmentAttempts(assessmentId)); + } +} + diff --git a/src/main/java/uy/supap/controller/AssignmentController.java b/src/main/java/uy/supap/controller/AssignmentController.java new file mode 100644 index 0000000..c7f8ef5 --- /dev/null +++ b/src/main/java/uy/supap/controller/AssignmentController.java @@ -0,0 +1,126 @@ +package uy.supap.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import uy.supap.model.dto.request.AssignmentRequest; +import uy.supap.model.dto.request.GradeSubmissionRequest; +import uy.supap.model.dto.request.SubmissionRequest; +import uy.supap.model.dto.response.AssignmentResponse; +import uy.supap.model.dto.response.SubmissionResponse; +import uy.supap.model.entity.Submission; +import uy.supap.service.AssignmentService; +import uy.supap.service.SubmissionService; + +import java.util.List; + +/** + * Assignment controller. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/assignments") +@RequiredArgsConstructor +@Tag(name = "Assignments", description = "Assignment management endpoints") +public class AssignmentController { + + private final AssignmentService assignmentService; + private final SubmissionService submissionService; + + @GetMapping("/lesson/{lessonId}") + @Operation(summary = "Get assignments by lesson", description = "Returns all assignments for a lesson") + public ResponseEntity> getAssignmentsByLesson(@PathVariable Long lessonId) { + return ResponseEntity.ok(assignmentService.getAssignmentsByLesson(lessonId)); + } + + @GetMapping("/{id}") + @Operation(summary = "Get assignment by ID", description = "Returns assignment details") + public ResponseEntity getAssignmentById(@PathVariable Long id) { + return ResponseEntity.ok(assignmentService.getAssignmentById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'INSTRUCTOR')") + @Operation(summary = "Create assignment", description = "Creates a new assignment (Admin/Instructor)") + public ResponseEntity createAssignment(@Valid @RequestBody AssignmentRequest request) { + log.info("Creating assignment: {}", request.getTitle()); + AssignmentResponse assignment = assignmentService.createAssignment(request); + return ResponseEntity.status(HttpStatus.CREATED).body(assignment); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'INSTRUCTOR')") + @Operation(summary = "Update assignment", description = "Updates an existing assignment (Admin/Instructor)") + public ResponseEntity updateAssignment( + @PathVariable Long id, + @Valid @RequestBody AssignmentRequest request) { + log.info("Updating assignment: {}", id); + return ResponseEntity.ok(assignmentService.updateAssignment(id, request)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'INSTRUCTOR')") + @Operation(summary = "Delete assignment", description = "Deletes an assignment (Admin/Instructor)") + public ResponseEntity deleteAssignment(@PathVariable Long id) { + log.info("Deleting assignment: {}", id); + assignmentService.deleteAssignment(id); + return ResponseEntity.noContent().build(); + } + + // Submission endpoints + @PostMapping("/submissions") + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Create or update submission", description = "Creates or updates a submission (User)") + public ResponseEntity createOrUpdateSubmission(@Valid @RequestBody SubmissionRequest request) { + SubmissionResponse submission = submissionService.createOrUpdateSubmission(request); + return ResponseEntity.status(HttpStatus.CREATED).body(submission); + } + + @PostMapping("/submissions/{id}/submit") + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Submit assignment", description = "Submits an assignment (User)") + public ResponseEntity submitAssignment(@PathVariable Long id) { + return ResponseEntity.ok(submissionService.submitAssignment(id)); + } + + @GetMapping("/submissions/my") + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Get my submissions", description = "Returns current user's submissions") + public ResponseEntity> getMySubmissions() { + return ResponseEntity.ok(submissionService.getMySubmissions(org.springframework.data.domain.Pageable.unpaged()) + .getContent()); + } + + @GetMapping("/submissions/{id}") + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Get submission by ID", description = "Returns submission details") + public ResponseEntity getSubmissionById(@PathVariable Long id) { + return ResponseEntity.ok(submissionService.getSubmissionById(id)); + } + + @GetMapping("/{assignmentId}/submissions") + @PreAuthorize("hasAnyRole('ADMIN', 'INSTRUCTOR')") + @Operation(summary = "Get submissions for assignment", description = "Returns all submissions for an assignment (Admin/Instructor)") + public ResponseEntity> getSubmissionsByAssignment(@PathVariable Long assignmentId) { + return ResponseEntity.ok(submissionService.getSubmissionsByAssignment( + assignmentId, + org.springframework.data.domain.Pageable.unpaged()) + .getContent()); + } + + @PostMapping("/submissions/{id}/grade") + @PreAuthorize("hasAnyRole('ADMIN', 'INSTRUCTOR')") + @Operation(summary = "Grade submission", description = "Grades a submission (Admin/Instructor)") + public ResponseEntity gradeSubmission( + @PathVariable Long id, + @Valid @RequestBody GradeSubmissionRequest request) { + return ResponseEntity.ok(submissionService.gradeSubmission(id, request)); + } +} + diff --git a/src/main/java/uy/supap/model/dto/request/AssessmentAttemptRequest.java b/src/main/java/uy/supap/model/dto/request/AssessmentAttemptRequest.java new file mode 100644 index 0000000..d1208a2 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/request/AssessmentAttemptRequest.java @@ -0,0 +1,23 @@ +package uy.supap.model.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Assessment attempt request DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AssessmentAttemptRequest { + + @NotNull(message = "Assessment ID is required") + private Long assessmentId; + + private String answers; // JSON +} + diff --git a/src/main/java/uy/supap/model/dto/request/AssessmentRequest.java b/src/main/java/uy/supap/model/dto/request/AssessmentRequest.java new file mode 100644 index 0000000..1c301cd --- /dev/null +++ b/src/main/java/uy/supap/model/dto/request/AssessmentRequest.java @@ -0,0 +1,33 @@ +package uy.supap.model.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Assessment creation/update request DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AssessmentRequest { + + private Long lessonId; + + @NotBlank(message = "Title is required") + @Size(max = 255, message = "Title must not exceed 255 characters") + private String title; + + private String questions; // JSON structure + + private Integer timeLimit; // minutes + + private Integer passingScore; + + private Integer maxAttempts; +} + diff --git a/src/main/java/uy/supap/model/dto/request/AssignmentRequest.java b/src/main/java/uy/supap/model/dto/request/AssignmentRequest.java new file mode 100644 index 0000000..d54a834 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/request/AssignmentRequest.java @@ -0,0 +1,33 @@ +package uy.supap.model.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Assignment creation/update request DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AssignmentRequest { + + private Long lessonId; + + @NotBlank(message = "Title is required") + @Size(max = 255, message = "Title must not exceed 255 characters") + private String title; + + private String instructions; + + private LocalDateTime dueDate; + + private Integer maxScore; +} + diff --git a/src/main/java/uy/supap/model/dto/request/GradeSubmissionRequest.java b/src/main/java/uy/supap/model/dto/request/GradeSubmissionRequest.java new file mode 100644 index 0000000..acf9937 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/request/GradeSubmissionRequest.java @@ -0,0 +1,27 @@ +package uy.supap.model.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Grade submission request DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GradeSubmissionRequest { + + @NotNull(message = "Score is required") + @Min(value = 0, message = "Score must be at least 0") + @Max(value = 100, message = "Score must be at most 100") + private Integer score; + + private String feedback; +} + diff --git a/src/main/java/uy/supap/model/dto/request/SubmissionRequest.java b/src/main/java/uy/supap/model/dto/request/SubmissionRequest.java new file mode 100644 index 0000000..9ef9cfe --- /dev/null +++ b/src/main/java/uy/supap/model/dto/request/SubmissionRequest.java @@ -0,0 +1,27 @@ +package uy.supap.model.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Submission creation/update request DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubmissionRequest { + + @NotNull(message = "Assignment ID is required") + private Long assignmentId; + + private String content; + + @Size(max = 500, message = "File URL must not exceed 500 characters") + private String fileUrl; +} + diff --git a/src/main/java/uy/supap/model/dto/response/AssessmentAttemptResponse.java b/src/main/java/uy/supap/model/dto/response/AssessmentAttemptResponse.java new file mode 100644 index 0000000..dc53c4a --- /dev/null +++ b/src/main/java/uy/supap/model/dto/response/AssessmentAttemptResponse.java @@ -0,0 +1,56 @@ +package uy.supap.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import uy.supap.model.entity.AssessmentAttempt; + +import java.time.LocalDateTime; + +/** + * Assessment attempt response DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AssessmentAttemptResponse { + + private Long id; + private Long assessmentId; + private String assessmentTitle; + private Long userId; + private String userName; + private String answers; + private Integer score; + private Boolean passed; + private LocalDateTime startedAt; + private LocalDateTime completedAt; + private Integer attemptNumber; + + /** + * Convert AssessmentAttempt entity to AssessmentAttemptResponse DTO. + * + * @param attempt the attempt entity + * @return AssessmentAttemptResponse DTO + */ + public static AssessmentAttemptResponse fromEntity(AssessmentAttempt attempt) { + return AssessmentAttemptResponse.builder() + .id(attempt.getId()) + .assessmentId(attempt.getAssessment() != null ? attempt.getAssessment().getId() : null) + .assessmentTitle(attempt.getAssessment() != null ? attempt.getAssessment().getTitle() : null) + .userId(attempt.getUser() != null ? attempt.getUser().getId() : null) + .userName(attempt.getUser() != null + ? (attempt.getUser().getFirstName() + " " + attempt.getUser().getLastName()).trim() + : null) + .answers(attempt.getAnswers()) + .score(attempt.getScore()) + .passed(attempt.getPassed()) + .startedAt(attempt.getStartedAt()) + .completedAt(attempt.getCompletedAt()) + .attemptNumber(attempt.getAttemptNumber()) + .build(); + } +} + diff --git a/src/main/java/uy/supap/model/dto/response/AssessmentResponse.java b/src/main/java/uy/supap/model/dto/response/AssessmentResponse.java new file mode 100644 index 0000000..dfb9acd --- /dev/null +++ b/src/main/java/uy/supap/model/dto/response/AssessmentResponse.java @@ -0,0 +1,46 @@ +package uy.supap.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import uy.supap.model.entity.Assessment; + +/** + * Assessment response DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AssessmentResponse { + + private Long id; + private Long lessonId; + private String lessonTitle; + private String title; + private String questions; + private Integer timeLimit; + private Integer passingScore; + private Integer maxAttempts; + + /** + * Convert Assessment entity to AssessmentResponse DTO. + * + * @param assessment the assessment entity + * @return AssessmentResponse DTO + */ + public static AssessmentResponse fromEntity(Assessment assessment) { + return AssessmentResponse.builder() + .id(assessment.getId()) + .lessonId(assessment.getLesson() != null ? assessment.getLesson().getId() : null) + .lessonTitle(assessment.getLesson() != null ? assessment.getLesson().getTitle() : null) + .title(assessment.getTitle()) + .questions(assessment.getQuestions()) + .timeLimit(assessment.getTimeLimit()) + .passingScore(assessment.getPassingScore()) + .maxAttempts(assessment.getMaxAttempts()) + .build(); + } +} + diff --git a/src/main/java/uy/supap/model/dto/response/AssignmentResponse.java b/src/main/java/uy/supap/model/dto/response/AssignmentResponse.java new file mode 100644 index 0000000..2b007bd --- /dev/null +++ b/src/main/java/uy/supap/model/dto/response/AssignmentResponse.java @@ -0,0 +1,46 @@ +package uy.supap.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import uy.supap.model.entity.Assignment; + +import java.time.LocalDateTime; + +/** + * Assignment response DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AssignmentResponse { + + private Long id; + private Long lessonId; + private String lessonTitle; + private String title; + private String instructions; + private LocalDateTime dueDate; + private Integer maxScore; + + /** + * Convert Assignment entity to AssignmentResponse DTO. + * + * @param assignment the assignment entity + * @return AssignmentResponse DTO + */ + public static AssignmentResponse fromEntity(Assignment assignment) { + return AssignmentResponse.builder() + .id(assignment.getId()) + .lessonId(assignment.getLesson() != null ? assignment.getLesson().getId() : null) + .lessonTitle(assignment.getLesson() != null ? assignment.getLesson().getTitle() : null) + .title(assignment.getTitle()) + .instructions(assignment.getInstructions()) + .dueDate(assignment.getDueDate()) + .maxScore(assignment.getMaxScore()) + .build(); + } +} + diff --git a/src/main/java/uy/supap/model/dto/response/SubmissionResponse.java b/src/main/java/uy/supap/model/dto/response/SubmissionResponse.java new file mode 100644 index 0000000..8a5dea9 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/response/SubmissionResponse.java @@ -0,0 +1,64 @@ +package uy.supap.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import uy.supap.model.entity.Submission; + +import java.time.LocalDateTime; + +/** + * Submission response DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubmissionResponse { + + private Long id; + private Long assignmentId; + private String assignmentTitle; + private Long userId; + private String userName; + private String content; + private String fileUrl; + private Submission.SubmissionStatus status; + private LocalDateTime submittedAt; + private Integer score; + private String feedback; + private LocalDateTime gradedAt; + private Long gradedById; + private String gradedByName; + + /** + * Convert Submission entity to SubmissionResponse DTO. + * + * @param submission the submission entity + * @return SubmissionResponse DTO + */ + public static SubmissionResponse fromEntity(Submission submission) { + return SubmissionResponse.builder() + .id(submission.getId()) + .assignmentId(submission.getAssignment() != null ? submission.getAssignment().getId() : null) + .assignmentTitle(submission.getAssignment() != null ? submission.getAssignment().getTitle() : null) + .userId(submission.getUser() != null ? submission.getUser().getId() : null) + .userName(submission.getUser() != null + ? (submission.getUser().getFirstName() + " " + submission.getUser().getLastName()).trim() + : null) + .content(submission.getContent()) + .fileUrl(submission.getFileUrl()) + .status(submission.getStatus()) + .submittedAt(submission.getSubmittedAt()) + .score(submission.getScore()) + .feedback(submission.getFeedback()) + .gradedAt(submission.getGradedAt()) + .gradedById(submission.getGradedBy() != null ? submission.getGradedBy().getId() : null) + .gradedByName(submission.getGradedBy() != null + ? (submission.getGradedBy().getFirstName() + " " + submission.getGradedBy().getLastName()).trim() + : null) + .build(); + } +} + diff --git a/src/main/java/uy/supap/model/entity/Assessment.java b/src/main/java/uy/supap/model/entity/Assessment.java new file mode 100644 index 0000000..79b56cd --- /dev/null +++ b/src/main/java/uy/supap/model/entity/Assessment.java @@ -0,0 +1,49 @@ +package uy.supap.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.util.ArrayList; +import java.util.List; + +/** + * Assessment entity representing quizzes/exams within lessons. + */ +@Entity +@Table(name = "assessments", indexes = { + @Index(name = "idx_assessments_lesson", columnList = "lesson_id") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Assessment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "lesson_id") + private Lesson lesson; + + @Column(nullable = false, length = 255) + private String title; + + @Column(columnDefinition = "TEXT") + private String questions; // JSON structure + + @Column(name = "time_limit") + private Integer timeLimit; // minutes + + @Column(name = "passing_score") + private Integer passingScore; + + @Column(name = "max_attempts") + private Integer maxAttempts; + + @OneToMany(mappedBy = "assessment", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List attempts = new ArrayList<>(); +} + diff --git a/src/main/java/uy/supap/model/entity/AssessmentAttempt.java b/src/main/java/uy/supap/model/entity/AssessmentAttempt.java new file mode 100644 index 0000000..fe6ea80 --- /dev/null +++ b/src/main/java/uy/supap/model/entity/AssessmentAttempt.java @@ -0,0 +1,56 @@ +package uy.supap.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +/** + * AssessmentAttempt entity representing student attempts at assessments. + */ +@Entity +@Table(name = "assessment_attempts", indexes = { + @Index(name = "idx_attempts_assessment", columnList = "assessment_id"), + @Index(name = "idx_attempts_user", columnList = "user_id") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AssessmentAttempt { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assessment_id", nullable = false) + private Assessment assessment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(columnDefinition = "TEXT") + private String answers; // JSON + + @Column + private Integer score; + + @Column(nullable = false) + @Builder.Default + private Boolean passed = false; + + @CreationTimestamp + @Column(name = "started_at", nullable = false, updatable = false) + private LocalDateTime startedAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Column(name = "attempt_number") + private Integer attemptNumber; +} + diff --git a/src/main/java/uy/supap/model/entity/Assignment.java b/src/main/java/uy/supap/model/entity/Assignment.java new file mode 100644 index 0000000..63886e8 --- /dev/null +++ b/src/main/java/uy/supap/model/entity/Assignment.java @@ -0,0 +1,47 @@ +package uy.supap.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Assignment entity representing assignments within lessons. + */ +@Entity +@Table(name = "assignments", indexes = { + @Index(name = "idx_assignments_lesson", columnList = "lesson_id") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Assignment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "lesson_id") + private Lesson lesson; + + @Column(nullable = false, length = 255) + private String title; + + @Column(columnDefinition = "TEXT") + private String instructions; + + @Column(name = "due_date") + private LocalDateTime dueDate; + + @Column(name = "max_score") + private Integer maxScore; + + @OneToMany(mappedBy = "assignment", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List submissions = new ArrayList<>(); +} + diff --git a/src/main/java/uy/supap/model/entity/Submission.java b/src/main/java/uy/supap/model/entity/Submission.java new file mode 100644 index 0000000..9e05328 --- /dev/null +++ b/src/main/java/uy/supap/model/entity/Submission.java @@ -0,0 +1,75 @@ +package uy.supap.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +/** + * Submission entity representing student submissions for assignments. + */ +@Entity +@Table(name = "submissions", indexes = { + @Index(name = "idx_submissions_assignment", columnList = "assignment_id"), + @Index(name = "idx_submissions_user", columnList = "user_id"), + @Index(name = "idx_submissions_status", columnList = "status") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Submission { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assignment_id", nullable = false) + private Assignment assignment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(columnDefinition = "TEXT") + private String content; + + @Column(name = "file_url", length = 500) + private String fileUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private SubmissionStatus status = SubmissionStatus.DRAFT; + + @CreationTimestamp + @Column(name = "submitted_at", nullable = false, updatable = false) + private LocalDateTime submittedAt; + + @Column + private Integer score; + + @Column(columnDefinition = "TEXT") + private String feedback; + + @Column(name = "graded_at") + private LocalDateTime gradedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "graded_by") + private User gradedBy; + + /** + * Submission status enum. + */ + public enum SubmissionStatus { + DRAFT, + SUBMITTED, + GRADED, + RETURNED + } +} + diff --git a/src/main/java/uy/supap/repository/AssessmentAttemptRepository.java b/src/main/java/uy/supap/repository/AssessmentAttemptRepository.java new file mode 100644 index 0000000..8079cae --- /dev/null +++ b/src/main/java/uy/supap/repository/AssessmentAttemptRepository.java @@ -0,0 +1,56 @@ +package uy.supap.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import uy.supap.model.entity.AssessmentAttempt; + +import java.util.List; +import java.util.Optional; + +/** + * Repository interface for AssessmentAttempt entity. + */ +@Repository +public interface AssessmentAttemptRepository extends JpaRepository { + + /** + * Find all attempts for an assessment by a user. + * + * @param assessmentId the assessment ID + * @param userId the user ID + * @return list of attempts + */ + List findByAssessmentIdAndUserId(Long assessmentId, Long userId); + + /** + * Find all attempts for an assessment. + * + * @param assessmentId the assessment ID + * @return list of attempts + */ + List findByAssessmentId(Long assessmentId); + + /** + * Count attempts by user for an assessment. + * + * @param assessmentId the assessment ID + * @param userId the user ID + * @return count of attempts + */ + long countByAssessmentIdAndUserId(Long assessmentId, Long userId); + + /** + * Find the latest attempt for an assessment by a user. + * + * @param assessmentId the assessment ID + * @param userId the user ID + * @return Optional containing the latest attempt + */ + @Query("SELECT a FROM AssessmentAttempt a WHERE a.assessment.id = :assessmentId AND a.user.id = :userId ORDER BY a.attemptNumber DESC") + Optional findLatestAttemptByAssessmentIdAndUserId( + @Param("assessmentId") Long assessmentId, + @Param("userId") Long userId); +} + diff --git a/src/main/java/uy/supap/repository/AssessmentRepository.java b/src/main/java/uy/supap/repository/AssessmentRepository.java new file mode 100644 index 0000000..75290a9 --- /dev/null +++ b/src/main/java/uy/supap/repository/AssessmentRepository.java @@ -0,0 +1,23 @@ +package uy.supap.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uy.supap.model.entity.Assessment; + +import java.util.List; + +/** + * Repository interface for Assessment entity. + */ +@Repository +public interface AssessmentRepository extends JpaRepository { + + /** + * Find all assessments for a lesson. + * + * @param lessonId the lesson ID + * @return list of assessments + */ + List findByLessonId(Long lessonId); +} + diff --git a/src/main/java/uy/supap/repository/AssignmentRepository.java b/src/main/java/uy/supap/repository/AssignmentRepository.java new file mode 100644 index 0000000..f4efe3b --- /dev/null +++ b/src/main/java/uy/supap/repository/AssignmentRepository.java @@ -0,0 +1,23 @@ +package uy.supap.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uy.supap.model.entity.Assignment; + +import java.util.List; + +/** + * Repository interface for Assignment entity. + */ +@Repository +public interface AssignmentRepository extends JpaRepository { + + /** + * Find all assignments for a lesson. + * + * @param lessonId the lesson ID + * @return list of assignments + */ + List findByLessonId(Long lessonId); +} + diff --git a/src/main/java/uy/supap/repository/SubmissionRepository.java b/src/main/java/uy/supap/repository/SubmissionRepository.java new file mode 100644 index 0000000..b5955c2 --- /dev/null +++ b/src/main/java/uy/supap/repository/SubmissionRepository.java @@ -0,0 +1,67 @@ +package uy.supap.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uy.supap.model.entity.Submission; + +import java.util.List; +import java.util.Optional; + +/** + * Repository interface for Submission entity. + */ +@Repository +public interface SubmissionRepository extends JpaRepository { + + /** + * Find submission by assignment and user. + * + * @param assignmentId the assignment ID + * @param userId the user ID + * @return Optional containing submission if found + */ + Optional findByAssignmentIdAndUserId(Long assignmentId, Long userId); + + /** + * Find all submissions for an assignment. + * + * @param assignmentId the assignment ID + * @param pageable pagination information + * @return page of submissions + */ + Page findByAssignmentId(Long assignmentId, Pageable pageable); + + /** + * Find all submissions for a user. + * + * @param userId the user ID + * @param pageable pagination information + * @return page of submissions + */ + Page findByUserId(Long userId, Pageable pageable); + + /** + * Find submissions by status for an assignment. + * + * @param assignmentId the assignment ID + * @param status the submission status + * @param pageable pagination information + * @return page of submissions + */ + Page findByAssignmentIdAndStatus( + Long assignmentId, + Submission.SubmissionStatus status, + Pageable pageable); + + /** + * Find all submissions for a user in a specific assignment. + * + * @param userId the user ID + * @param assignmentId the assignment ID + * @return list of submissions + */ + List findByUserIdAndAssignmentId(Long userId, Long assignmentId); +} + diff --git a/src/main/java/uy/supap/service/AssessmentService.java b/src/main/java/uy/supap/service/AssessmentService.java new file mode 100644 index 0000000..ede97ec --- /dev/null +++ b/src/main/java/uy/supap/service/AssessmentService.java @@ -0,0 +1,231 @@ +package uy.supap.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import uy.supap.exception.EventRegistrationException; +import uy.supap.exception.ResourceNotFoundException; +import uy.supap.model.dto.request.AssessmentAttemptRequest; +import uy.supap.model.dto.request.AssessmentRequest; +import uy.supap.model.dto.response.AssessmentAttemptResponse; +import uy.supap.model.dto.response.AssessmentResponse; +import uy.supap.model.entity.Assessment; +import uy.supap.model.entity.AssessmentAttempt; +import uy.supap.model.entity.Lesson; +import uy.supap.model.entity.User; +import uy.supap.repository.AssessmentAttemptRepository; +import uy.supap.repository.AssessmentRepository; +import uy.supap.repository.LessonRepository; +import uy.supap.repository.UserRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Service for managing assessments and attempts. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AssessmentService { + + private final AssessmentRepository assessmentRepository; + private final AssessmentAttemptRepository attemptRepository; + private final LessonRepository lessonRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List getAssessmentsByLesson(Long lessonId) { + log.debug("Fetching assessments for lesson: {}", lessonId); + return assessmentRepository.findByLessonId(lessonId).stream() + .map(AssessmentResponse::fromEntity) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public AssessmentResponse getAssessmentById(Long id) { + log.debug("Fetching assessment by id: {}", id); + Assessment assessment = assessmentRepository.findById(id) + .orElseThrow(() -> { + log.warn("Assessment not found: {}", id); + return new ResourceNotFoundException("Assessment not found with id: " + id); + }); + return AssessmentResponse.fromEntity(assessment); + } + + @Transactional + public AssessmentResponse createAssessment(AssessmentRequest request) { + log.info("Creating new assessment: {}", request.getTitle()); + + Lesson lesson = null; + if (request.getLessonId() != null) { + lesson = lessonRepository.findById(request.getLessonId()) + .orElseThrow(() -> new ResourceNotFoundException("Lesson not found with id: " + request.getLessonId())); + } + + Assessment assessment = Assessment.builder() + .lesson(lesson) + .title(request.getTitle()) + .questions(request.getQuestions()) + .timeLimit(request.getTimeLimit()) + .passingScore(request.getPassingScore()) + .maxAttempts(request.getMaxAttempts()) + .build(); + + Assessment savedAssessment = assessmentRepository.save(assessment); + log.info("Assessment created successfully with id: {}", savedAssessment.getId()); + return AssessmentResponse.fromEntity(savedAssessment); + } + + @Transactional + public AssessmentResponse updateAssessment(Long id, AssessmentRequest request) { + log.info("Updating assessment: {}", id); + Assessment assessment = assessmentRepository.findById(id) + .orElseThrow(() -> { + log.warn("Assessment not found for update: {}", id); + return new ResourceNotFoundException("Assessment not found with id: " + id); + }); + + assessment.setTitle(request.getTitle()); + assessment.setQuestions(request.getQuestions()); + assessment.setTimeLimit(request.getTimeLimit()); + assessment.setPassingScore(request.getPassingScore()); + assessment.setMaxAttempts(request.getMaxAttempts()); + + if (request.getLessonId() != null) { + Lesson lesson = lessonRepository.findById(request.getLessonId()) + .orElseThrow(() -> new ResourceNotFoundException("Lesson not found with id: " + request.getLessonId())); + assessment.setLesson(lesson); + } + + Assessment updatedAssessment = assessmentRepository.save(assessment); + log.info("Assessment updated successfully: {}", id); + return AssessmentResponse.fromEntity(updatedAssessment); + } + + @Transactional + public void deleteAssessment(Long id) { + log.info("Deleting assessment: {}", id); + if (!assessmentRepository.existsById(id)) { + log.warn("Assessment not found for deletion: {}", id); + throw new ResourceNotFoundException("Assessment not found with id: " + id); + } + assessmentRepository.deleteById(id); + log.info("Assessment deleted successfully: {}", id); + } + + @Transactional + public AssessmentAttemptResponse startAttempt(AssessmentAttemptRequest request) { + log.info("Starting assessment attempt: {}", request.getAssessmentId()); + + // Get current user + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Get assessment + Assessment assessment = assessmentRepository.findById(request.getAssessmentId()) + .orElseThrow(() -> new ResourceNotFoundException("Assessment not found")); + + // Check max attempts + long attemptCount = attemptRepository.countByAssessmentIdAndUserId(request.getAssessmentId(), user.getId()); + if (assessment.getMaxAttempts() != null && attemptCount >= assessment.getMaxAttempts()) { + throw new EventRegistrationException("Maximum attempts reached for this assessment"); + } + + // Create new attempt + AssessmentAttempt attempt = AssessmentAttempt.builder() + .assessment(assessment) + .user(user) + .answers(request.getAnswers()) + .passed(false) + .attemptNumber((int) attemptCount + 1) + .build(); + + AssessmentAttempt savedAttempt = attemptRepository.save(attempt); + log.info("Assessment attempt started with id: {}", savedAttempt.getId()); + return AssessmentAttemptResponse.fromEntity(savedAttempt); + } + + @Transactional + public AssessmentAttemptResponse submitAttempt(Long attemptId, AssessmentAttemptRequest request) { + log.info("Submitting assessment attempt: {}", attemptId); + + // Get current user + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + AssessmentAttempt attempt = attemptRepository.findById(attemptId) + .orElseThrow(() -> new ResourceNotFoundException("Assessment attempt not found")); + + // Verify user owns this attempt + if (!attempt.getUser().getId().equals(user.getId())) { + throw new EventRegistrationException("You are not authorized to submit this attempt"); + } + + if (attempt.getCompletedAt() != null) { + throw new EventRegistrationException("Attempt is already completed"); + } + + // Update answers + attempt.setAnswers(request.getAnswers()); + + // Calculate score and passed status (simplified - in production would use proper scoring logic) + // For now, we'll set a placeholder score. Real implementation would parse questions JSON + // and calculate based on correct answers + Assessment assessment = attempt.getAssessment(); + Integer score = calculateScore(attempt.getAnswers(), assessment.getQuestions()); + attempt.setScore(score); + attempt.setPassed(assessment.getPassingScore() != null && score >= assessment.getPassingScore()); + attempt.setCompletedAt(LocalDateTime.now()); + + AssessmentAttempt savedAttempt = attemptRepository.save(attempt); + log.info("Assessment attempt submitted successfully: {}", attemptId); + return AssessmentAttemptResponse.fromEntity(savedAttempt); + } + + @Transactional(readOnly = true) + public List getMyAttempts(Long assessmentId) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + return attemptRepository.findByAssessmentIdAndUserId(assessmentId, user.getId()).stream() + .map(AssessmentAttemptResponse::fromEntity) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getAssessmentAttempts(Long assessmentId) { + return attemptRepository.findByAssessmentId(assessmentId).stream() + .map(AssessmentAttemptResponse::fromEntity) + .collect(Collectors.toList()); + } + + /** + * Calculate score from answers (simplified implementation). + * In production, this would parse the questions JSON and compare with answers. + * + * @param answers the student answers (JSON) + * @param questions the assessment questions (JSON) + * @return calculated score + */ + private Integer calculateScore(String answers, String questions) { + // Placeholder implementation + // In production, this would: + // 1. Parse questions JSON to get correct answers + // 2. Parse answers JSON to get student answers + // 3. Compare and calculate score + // For now, return a placeholder + return 0; + } +} + diff --git a/src/main/java/uy/supap/service/AssignmentService.java b/src/main/java/uy/supap/service/AssignmentService.java new file mode 100644 index 0000000..4c1aa2a --- /dev/null +++ b/src/main/java/uy/supap/service/AssignmentService.java @@ -0,0 +1,107 @@ +package uy.supap.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import uy.supap.exception.ResourceNotFoundException; +import uy.supap.model.dto.request.AssignmentRequest; +import uy.supap.model.dto.response.AssignmentResponse; +import uy.supap.model.entity.Assignment; +import uy.supap.model.entity.Lesson; +import uy.supap.repository.AssignmentRepository; +import uy.supap.repository.LessonRepository; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Service for managing assignments. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AssignmentService { + + private final AssignmentRepository assignmentRepository; + private final LessonRepository lessonRepository; + + @Transactional(readOnly = true) + public List getAssignmentsByLesson(Long lessonId) { + log.debug("Fetching assignments for lesson: {}", lessonId); + return assignmentRepository.findByLessonId(lessonId).stream() + .map(AssignmentResponse::fromEntity) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public AssignmentResponse getAssignmentById(Long id) { + log.debug("Fetching assignment by id: {}", id); + Assignment assignment = assignmentRepository.findById(id) + .orElseThrow(() -> { + log.warn("Assignment not found: {}", id); + return new ResourceNotFoundException("Assignment not found with id: " + id); + }); + return AssignmentResponse.fromEntity(assignment); + } + + @Transactional + public AssignmentResponse createAssignment(AssignmentRequest request) { + log.info("Creating new assignment: {}", request.getTitle()); + + Lesson lesson = null; + if (request.getLessonId() != null) { + lesson = lessonRepository.findById(request.getLessonId()) + .orElseThrow(() -> new ResourceNotFoundException("Lesson not found with id: " + request.getLessonId())); + } + + Assignment assignment = Assignment.builder() + .lesson(lesson) + .title(request.getTitle()) + .instructions(request.getInstructions()) + .dueDate(request.getDueDate()) + .maxScore(request.getMaxScore()) + .build(); + + Assignment savedAssignment = assignmentRepository.save(assignment); + log.info("Assignment created successfully with id: {}", savedAssignment.getId()); + return AssignmentResponse.fromEntity(savedAssignment); + } + + @Transactional + public AssignmentResponse updateAssignment(Long id, AssignmentRequest request) { + log.info("Updating assignment: {}", id); + Assignment assignment = assignmentRepository.findById(id) + .orElseThrow(() -> { + log.warn("Assignment not found for update: {}", id); + return new ResourceNotFoundException("Assignment not found with id: " + id); + }); + + assignment.setTitle(request.getTitle()); + assignment.setInstructions(request.getInstructions()); + assignment.setDueDate(request.getDueDate()); + assignment.setMaxScore(request.getMaxScore()); + + if (request.getLessonId() != null) { + Lesson lesson = lessonRepository.findById(request.getLessonId()) + .orElseThrow(() -> new ResourceNotFoundException("Lesson not found with id: " + request.getLessonId())); + assignment.setLesson(lesson); + } + + Assignment updatedAssignment = assignmentRepository.save(assignment); + log.info("Assignment updated successfully: {}", id); + return AssignmentResponse.fromEntity(updatedAssignment); + } + + @Transactional + public void deleteAssignment(Long id) { + log.info("Deleting assignment: {}", id); + if (!assignmentRepository.existsById(id)) { + log.warn("Assignment not found for deletion: {}", id); + throw new ResourceNotFoundException("Assignment not found with id: " + id); + } + assignmentRepository.deleteById(id); + log.info("Assignment deleted successfully: {}", id); + } +} + diff --git a/src/main/java/uy/supap/service/SubmissionService.java b/src/main/java/uy/supap/service/SubmissionService.java new file mode 100644 index 0000000..54afe97 --- /dev/null +++ b/src/main/java/uy/supap/service/SubmissionService.java @@ -0,0 +1,173 @@ +package uy.supap.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import uy.supap.exception.EventRegistrationException; +import uy.supap.exception.ResourceNotFoundException; +import uy.supap.model.dto.request.GradeSubmissionRequest; +import uy.supap.model.dto.request.SubmissionRequest; +import uy.supap.model.dto.response.SubmissionResponse; +import uy.supap.model.entity.Assignment; +import uy.supap.model.entity.Submission; +import uy.supap.model.entity.User; +import uy.supap.repository.AssignmentRepository; +import uy.supap.repository.SubmissionRepository; +import uy.supap.repository.UserRepository; + +import java.time.LocalDateTime; + +/** + * Service for managing submissions. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SubmissionService { + + private final SubmissionRepository submissionRepository; + private final AssignmentRepository assignmentRepository; + private final UserRepository userRepository; + + @Transactional + public SubmissionResponse createOrUpdateSubmission(SubmissionRequest request) { + log.info("Creating/updating submission for assignment: {}", request.getAssignmentId()); + + // Get current user + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Get assignment + Assignment assignment = assignmentRepository.findById(request.getAssignmentId()) + .orElseThrow(() -> new ResourceNotFoundException("Assignment not found")); + + // Find existing submission or create new + Submission submission = submissionRepository + .findByAssignmentIdAndUserId(request.getAssignmentId(), user.getId()) + .orElse(Submission.builder() + .assignment(assignment) + .user(user) + .status(Submission.SubmissionStatus.DRAFT) + .build()); + + // Update submission + submission.setContent(request.getContent()); + submission.setFileUrl(request.getFileUrl()); + + Submission savedSubmission = submissionRepository.save(submission); + log.info("Submission saved successfully with id: {}", savedSubmission.getId()); + return SubmissionResponse.fromEntity(savedSubmission); + } + + @Transactional + public SubmissionResponse submitAssignment(Long submissionId) { + log.info("Submitting assignment: {}", submissionId); + + // Get current user + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + Submission submission = submissionRepository.findById(submissionId) + .orElseThrow(() -> new ResourceNotFoundException("Submission not found")); + + // Verify user owns this submission + if (!submission.getUser().getId().equals(user.getId())) { + throw new EventRegistrationException("You are not authorized to submit this assignment"); + } + + if (submission.getStatus() == Submission.SubmissionStatus.SUBMITTED) { + throw new EventRegistrationException("Assignment is already submitted"); + } + + submission.setStatus(Submission.SubmissionStatus.SUBMITTED); + submission.setSubmittedAt(LocalDateTime.now()); + + Submission savedSubmission = submissionRepository.save(submission); + log.info("Assignment submitted successfully: {}", submissionId); + return SubmissionResponse.fromEntity(savedSubmission); + } + + @Transactional + public SubmissionResponse gradeSubmission(Long submissionId, GradeSubmissionRequest request) { + log.info("Grading submission: {}", submissionId); + + // Get current user (grader) + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + User grader = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + Submission submission = submissionRepository.findById(submissionId) + .orElseThrow(() -> new ResourceNotFoundException("Submission not found")); + + submission.setScore(request.getScore()); + submission.setFeedback(request.getFeedback()); + submission.setStatus(Submission.SubmissionStatus.GRADED); + submission.setGradedAt(LocalDateTime.now()); + submission.setGradedBy(grader); + + Submission savedSubmission = submissionRepository.save(submission); + log.info("Submission graded successfully: {}", submissionId); + return SubmissionResponse.fromEntity(savedSubmission); + } + + @Transactional(readOnly = true) + public Page getMySubmissions(Pageable pageable) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + return submissionRepository.findByUserId(user.getId(), pageable) + .map(SubmissionResponse::fromEntity); + } + + @Transactional(readOnly = true) + public SubmissionResponse getSubmissionById(Long id) { + log.debug("Fetching submission by id: {}", id); + Submission submission = submissionRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Submission not found")); + + // Get current user + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Verify user owns this submission or is instructor/admin + boolean isOwner = submission.getUser().getId().equals(user.getId()); + boolean isInstructor = user.getRoles().stream() + .anyMatch(role -> role.getName().name().equals("ROLE_INSTRUCTOR") || role.getName().name().equals("ROLE_ADMIN")); + + if (!isOwner && !isInstructor) { + throw new EventRegistrationException("You are not authorized to access this submission"); + } + + return SubmissionResponse.fromEntity(submission); + } + + @Transactional(readOnly = true) + public Page getSubmissionsByAssignment(Long assignmentId, Pageable pageable) { + return submissionRepository.findByAssignmentId(assignmentId, pageable) + .map(SubmissionResponse::fromEntity); + } + + @Transactional(readOnly = true) + public Page getSubmissionsByAssignmentAndStatus( + Long assignmentId, + Submission.SubmissionStatus status, + Pageable pageable) { + return submissionRepository.findByAssignmentIdAndStatus(assignmentId, status, pageable) + .map(SubmissionResponse::fromEntity); + } +} + diff --git a/src/main/resources/db/migration/V6__create_assignments_assessments_tables.sql b/src/main/resources/db/migration/V6__create_assignments_assessments_tables.sql new file mode 100644 index 0000000..6c18edc --- /dev/null +++ b/src/main/resources/db/migration/V6__create_assignments_assessments_tables.sql @@ -0,0 +1,61 @@ +-- SUPAP Backend - Assessments & Assignments Module +-- Phase 5: Assessments & Assignments - Create assignments, submissions, assessments, and attempts tables + +-- Assignments table +CREATE TABLE assignments ( + id BIGSERIAL PRIMARY KEY, + lesson_id BIGINT REFERENCES lessons(id) ON DELETE SET NULL, + title VARCHAR(255) NOT NULL, + instructions TEXT, + due_date TIMESTAMP, + max_score INTEGER +); + +-- Submissions table +CREATE TABLE submissions ( + id BIGSERIAL PRIMARY KEY, + assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT, + file_url VARCHAR(500), + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + submitted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + score INTEGER, + feedback TEXT, + graded_at TIMESTAMP, + graded_by BIGINT REFERENCES users(id) ON DELETE SET NULL +); + +-- Assessments table +CREATE TABLE assessments ( + id BIGSERIAL PRIMARY KEY, + lesson_id BIGINT REFERENCES lessons(id) ON DELETE SET NULL, + title VARCHAR(255) NOT NULL, + questions TEXT, + time_limit INTEGER, + passing_score INTEGER, + max_attempts INTEGER +); + +-- Assessment attempts table +CREATE TABLE assessment_attempts ( + id BIGSERIAL PRIMARY KEY, + assessment_id BIGINT NOT NULL REFERENCES assessments(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + answers TEXT, + score INTEGER, + passed BOOLEAN NOT NULL DEFAULT FALSE, + started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + attempt_number INTEGER +); + +-- Indexes for performance +CREATE INDEX idx_assignments_lesson ON assignments(lesson_id); +CREATE INDEX idx_submissions_assignment ON submissions(assignment_id); +CREATE INDEX idx_submissions_user ON submissions(user_id); +CREATE INDEX idx_submissions_status ON submissions(status); +CREATE INDEX idx_assessments_lesson ON assessments(lesson_id); +CREATE INDEX idx_attempts_assessment ON assessment_attempts(assessment_id); +CREATE INDEX idx_attempts_user ON assessment_attempts(user_id); +