diff --git a/src/main/java/fr/univ_lille/gitlab/classrooms/assignments/AssignmentController.java b/src/main/java/fr/univ_lille/gitlab/classrooms/assignments/AssignmentController.java index 54fd780..ef5ba13 100644 --- a/src/main/java/fr/univ_lille/gitlab/classrooms/assignments/AssignmentController.java +++ b/src/main/java/fr/univ_lille/gitlab/classrooms/assignments/AssignmentController.java @@ -47,6 +47,7 @@ class AssignmentController { String viewAssignment(@PathVariable UUID assignmentId, Model model) { var assignment = this.assignmentService.getAssignment(assignmentId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); var assignmentResults = this.assignmentService.getAssignmentResults(assignment); + model.addAttribute("assignment", assignment); if (assignment.getType() == AssignmentType.QUIZ) { var quizAssignment = (QuizAssignment) assignment; diff --git a/src/main/java/fr/univ_lille/gitlab/classrooms/assignments/ExportAssignmentResultController.java b/src/main/java/fr/univ_lille/gitlab/classrooms/assignments/ExportAssignmentResultController.java new file mode 100644 index 0000000..905ce78 --- /dev/null +++ b/src/main/java/fr/univ_lille/gitlab/classrooms/assignments/ExportAssignmentResultController.java @@ -0,0 +1,79 @@ +package fr.univ_lille.gitlab.classrooms.assignments; + +import jakarta.annotation.security.RolesAllowed; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.format.DateTimeFormatter; +import java.util.StringJoiner; +import java.util.UUID; + +@Controller +@RolesAllowed("TEACHER") +class ExportAssignmentResultController { + + private final ExportAssignmentResultUseCase exportUseCase; + + ExportAssignmentResultController(ExportAssignmentResultUseCase exportUseCase) { + this.exportUseCase = exportUseCase; + } + + @GetMapping(value = "/assignments/{assignmentId}/export.csv", produces = "text/csv") + ResponseEntity exportCsv(@PathVariable UUID assignmentId) { + var data = exportUseCase.getExportData(assignmentId); + + var csv = buildCsv(data); + + var filenameBase = (data.assignmentName() != null && !data.assignmentName().isBlank()) + ? data.assignmentName() + : data.assignmentId().toString(); + var filename = "assignment-results-" + filenameBase + ".csv"; + var encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + encodedFilename + "\"") + .contentType(new MediaType("text","csv", StandardCharsets.UTF_8)) + .body(csv); + } + + private String buildCsv(ExportAssignmentResultUseCase.ExportData data) { + var sb = new StringBuilder(); + // Header + sb.append("student email,student name,assignment score,max score,retakes,submission date\n"); + + var dtf = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + for (var row : data.rows()) { + String email = safeCsv(row.studentEmail()); + String name = safeCsv(row.studentName()); + String score = row.assignmentScore() == null ? "" : row.assignmentScore().toString(); + String maxScore = row.maxScore() == null ? "" : row.maxScore().toString(); + String retakes = ""; + if (data.assignmentType() == AssignmentType.QUIZ) { + retakes = row.retakes() == null ? "" : row.retakes().toString(); + } + String date = row.submissionDate() == null ? "" : dtf.format(row.submissionDate()); + + StringJoiner joiner = new StringJoiner(","); + joiner.add(email).add(name).add(score).add(maxScore).add(retakes).add(safeCsv(date)); + sb.append(joiner).append("\n"); + } + return sb.toString(); + } + + private String safeCsv(String value) { + if (value == null) return ""; + boolean mustQuote = value.contains(",") || value.contains("\n") || value.contains("\r") || value.contains("\""); + String processed = value.replace("\"", "\"\""); + if (mustQuote) { + return "\"" + processed + "\""; + } + return processed; + } +} diff --git a/src/main/java/fr/univ_lille/gitlab/classrooms/assignments/ExportAssignmentResultUseCase.java b/src/main/java/fr/univ_lille/gitlab/classrooms/assignments/ExportAssignmentResultUseCase.java new file mode 100644 index 0000000..4f5f891 --- /dev/null +++ b/src/main/java/fr/univ_lille/gitlab/classrooms/assignments/ExportAssignmentResultUseCase.java @@ -0,0 +1,48 @@ +package fr.univ_lille.gitlab.classrooms.assignments; + +import org.springframework.stereotype.Component; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; + +@Component +public class ExportAssignmentResultUseCase { + + private final AssignmentService assignmentService; + + ExportAssignmentResultUseCase(AssignmentService assignmentService) { + this.assignmentService = assignmentService; + } + + public record ExportRow(String studentEmail, + String studentName, + Long assignmentScore, + Long maxScore, + Integer retakes, + ZonedDateTime submissionDate) {} + + public record ExportData(UUID assignmentId, String assignmentName, AssignmentType assignmentType, List rows) {} + + public ExportData getExportData(UUID assignmentId) { + var assignment = assignmentService.getAssignment(assignmentId).orElseThrow(); + var results = assignmentService.getAssignmentResults(assignment); + + var rows = results.stream().map(sa -> { + Integer retakes = null; + if (sa instanceof StudentQuizAssignment quiz) { + retakes = quiz.getRetakes(); + } + return new ExportRow( + sa.getStudent() != null ? sa.getStudent().getEmail() : null, + sa.getStudent() != null ? sa.getStudent().getName() : null, + sa.getScore(), + sa.getMaxScore(), + retakes, + sa.getSubmissionDate() + ); + }).toList(); + + return new ExportData(assignment.getId(), assignment.getName(), assignment.getType(), rows); + } +} diff --git a/src/main/resources/templates/exercise/all-submissions.html b/src/main/resources/templates/exercise/all-submissions.html index 44f7aea..116ffa5 100644 --- a/src/main/resources/templates/exercise/all-submissions.html +++ b/src/main/resources/templates/exercise/all-submissions.html @@ -11,6 +11,11 @@
name results.

List of all exercise submissions.

+ @@ -38,7 +43,7 @@
Average
10 / - 10<∕span> + 10
diff --git a/src/main/resources/templates/quiz/all-submissions.html b/src/main/resources/templates/quiz/all-submissions.html index e6f844a..9e05a2e 100644 --- a/src/main/resources/templates/quiz/all-submissions.html +++ b/src/main/resources/templates/quiz/all-submissions.html @@ -11,6 +11,11 @@
name results.

List of all quiz submissions.

+ diff --git a/src/test/java/fr/univ_lille/gitlab/classrooms/assignments/ExportAssignmentResultControllerTest.java b/src/test/java/fr/univ_lille/gitlab/classrooms/assignments/ExportAssignmentResultControllerTest.java new file mode 100644 index 0000000..38d26de --- /dev/null +++ b/src/test/java/fr/univ_lille/gitlab/classrooms/assignments/ExportAssignmentResultControllerTest.java @@ -0,0 +1,85 @@ +package fr.univ_lille.gitlab.classrooms.assignments; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.jdbc.core.JdbcOperations; +import fr.univ_lille.gitlab.classrooms.users.ClassroomUserService; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(controllers = ExportAssignmentResultController.class) +class ExportAssignmentResultControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ExportAssignmentResultUseCase exportAssignmentResultUseCase; + + // Mock OAuth2 client infrastructure required by security auto-configuration in MVC slice + @MockitoBean + private ClientRegistrationRepository clientRegistrationRepository; + + @MockitoBean + private OAuth2AuthorizedClientRepository authorizedClientRepository; + + @MockitoBean + private JdbcOperations jdbcOperations; + + @MockitoBean + private ClassroomUserService classroomUserService; + + @Test + @DisplayName("should return CSV export for given assignment id") + @WithMockUser(roles = {"TEACHER"}) + void exportCsv_returnsCsvContent() throws Exception { + // Given + UUID assignmentId = UUID.randomUUID(); + var row = new ExportAssignmentResultUseCase.ExportRow( + "alice@example.com", + "Alice", + 8L, + 10L, + 0, + null + ); + var data = new ExportAssignmentResultUseCase.ExportData( + assignmentId, + "My Quiz", + AssignmentType.QUIZ, + List.of(row) + ); + + String expectedCsv = """ + student email,student name,assignment score,max score,retakes,submission date + alice@example.com,Alice,8,10,0, + """; + + when(exportAssignmentResultUseCase.getExportData(assignmentId)).thenReturn(data); + + // When/Then + mockMvc.perform(get("/assignments/{assignmentId}/export.csv", assignmentId)) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("attachment; filename=\"assignment-results-"))) + .andExpect(content().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))) + .andExpect(content().string(expectedCsv)); + + // Verify interaction with use case + Mockito.verify(exportAssignmentResultUseCase).getExportData(assignmentId); + } +} diff --git a/src/test/java/fr/univ_lille/gitlab/classrooms/assignments/ExportAssignmentResultUseCaseTest.java b/src/test/java/fr/univ_lille/gitlab/classrooms/assignments/ExportAssignmentResultUseCaseTest.java new file mode 100644 index 0000000..88d770b --- /dev/null +++ b/src/test/java/fr/univ_lille/gitlab/classrooms/assignments/ExportAssignmentResultUseCaseTest.java @@ -0,0 +1,101 @@ +package fr.univ_lille.gitlab.classrooms.assignments; + +import fr.univ_lille.gitlab.classrooms.users.ClassroomUser; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +class ExportAssignmentResultUseCaseTest { + + @Test + @DisplayName("getExportData should map student fields, scores and retakes for quiz assignments") + void getExportData_mapsFieldsAndRetakes() { + // Arrange + var assignmentService = Mockito.mock(AssignmentService.class); + var useCase = new ExportAssignmentResultUseCase(assignmentService); + + UUID assignmentId = UUID.randomUUID(); + var assignment = Mockito.mock(Assignment.class); + when(assignment.getId()).thenReturn(assignmentId); + when(assignment.getName()).thenReturn("My Quiz"); + when(assignment.getType()).thenReturn(AssignmentType.QUIZ); + + when(assignmentService.getAssignment(assignmentId)).thenReturn(Optional.of(assignment)); + + var student = new ClassroomUser(); + student.setName("Alice"); + student.setEmail("alice@example.com"); + + var sqa = new StudentQuizAssignment(); + sqa.setStudent(student); + sqa.setScore(8L); + sqa.setMaxScore(10L); + sqa.setRetakes(2); + + when(assignmentService.getAssignmentResults(assignment)).thenReturn(List.of(sqa)); + + // Act + var data = useCase.getExportData(assignmentId); + + // Assert + assertThat(data.assignmentId()).isEqualTo(assignmentId); + assertThat(data.assignmentName()).isEqualTo("My Quiz"); + assertThat(data.assignmentType()).isEqualTo(AssignmentType.QUIZ); + assertThat(data.rows()).hasSize(1); + + var row = data.rows().getFirst(); + assertThat(row.studentEmail()).isEqualTo("alice@example.com"); + assertThat(row.studentName()).isEqualTo("Alice"); + assertThat(row.assignmentScore()).isEqualTo(8L); + assertThat(row.maxScore()).isEqualTo(10L); + assertThat(row.retakes()).isEqualTo(2); + assertThat(row.submissionDate()).isNull(); + } + + @Test + @DisplayName("getExportData should set retakes to null for non-quiz assignments") + void getExportData_setsRetakesNullForExercise() { + // Arrange + var assignmentService = Mockito.mock(AssignmentService.class); + var useCase = new ExportAssignmentResultUseCase(assignmentService); + + UUID assignmentId = UUID.randomUUID(); + var assignment = Mockito.mock(Assignment.class); + when(assignment.getId()).thenReturn(assignmentId); + when(assignment.getName()).thenReturn("Exercise 1"); + when(assignment.getType()).thenReturn(AssignmentType.EXERCISE); + + when(assignmentService.getAssignment(assignmentId)).thenReturn(Optional.of(assignment)); + + var student = new ClassroomUser(); + student.setName("Bob, the \"Builder\""); + student.setEmail("bob@example.com"); + + var sea = new StudentExerciseAssignment(); + sea.setStudent(student); + // no grades -> score and maxScore computed to 0 + + when(assignmentService.getAssignmentResults(assignment)).thenReturn(List.of(sea)); + + // Act + var data = useCase.getExportData(assignmentId); + + // Assert + assertThat(data.assignmentType()).isEqualTo(AssignmentType.EXERCISE); + assertThat(data.rows()).hasSize(1); + + var row = data.rows().getFirst(); + assertThat(row.studentEmail()).isEqualTo("bob@example.com"); + assertThat(row.studentName()).isEqualTo("Bob, the \"Builder\""); + assertThat(row.assignmentScore()).isZero(); + assertThat(row.maxScore()).isZero(); + assertThat(row.retakes()).isNull(); + } +}