diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java index 6eb12674..ae846f19 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/OcrController.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.BatchOcrDTO; import org.raddatz.familienarchiv.dto.OcrStatusDTO; +import org.raddatz.familienarchiv.dto.TrainingHistoryResponse; import org.raddatz.familienarchiv.dto.TrainingInfoResponse; import org.raddatz.familienarchiv.dto.TriggerOcrDTO; import org.raddatz.familienarchiv.model.AppUser; @@ -135,6 +136,18 @@ public class OcrController { return ocrTrainingService.getTrainingInfo(); } + @GetMapping("/api/ocr/training-info/global") + @RequirePermission(Permission.ADMIN) + public TrainingHistoryResponse getGlobalTrainingHistory() { + return ocrTrainingService.getGlobalTrainingHistory(); + } + + @GetMapping("/api/ocr/training-info/{personId}") + @RequirePermission(Permission.ADMIN) + public TrainingHistoryResponse getSenderTrainingHistory(@PathVariable UUID personId) { + return ocrTrainingService.getSenderTrainingHistory(personId); + } + private UUID resolveUserId(Authentication authentication) { if (authentication == null || !authentication.isAuthenticated()) return null; try { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TrainingHistoryResponse.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TrainingHistoryResponse.java new file mode 100644 index 00000000..10c1f42a --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TrainingHistoryResponse.java @@ -0,0 +1,11 @@ +package org.raddatz.familienarchiv.dto; + +import org.raddatz.familienarchiv.model.OcrTrainingRun; + +import java.util.List; +import java.util.Map; + +public record TrainingHistoryResponse( + List runs, + Map personNames +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrTrainingRunRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrTrainingRunRepository.java index b3878164..fb2e5ca9 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrTrainingRunRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/OcrTrainingRunRepository.java @@ -19,4 +19,8 @@ public interface OcrTrainingRunRepository extends JpaRepository findTop20ByOrderByCreatedAtDesc(); + + List findByPersonIdIsNullOrderByCreatedAtDesc(); + + List findByPersonIdOrderByCreatedAtDesc(UUID personId); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrTrainingService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrTrainingService.java index 02f2cda2..83e5086c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/OcrTrainingService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/OcrTrainingService.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.TrainingHistoryResponse; import org.raddatz.familienarchiv.dto.TrainingInfoResponse; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; @@ -219,6 +220,17 @@ public class OcrTrainingService { ); } + public TrainingHistoryResponse getGlobalTrainingHistory() { + List runs = trainingRunRepository.findByPersonIdIsNullOrderByCreatedAtDesc(); + return new TrainingHistoryResponse(runs, Map.of()); + } + + public TrainingHistoryResponse getSenderTrainingHistory(UUID personId) { + String personName = personService.getById(personId).getDisplayName(); + List runs = trainingRunRepository.findByPersonIdOrderByCreatedAtDesc(personId); + return new TrainingHistoryResponse(runs, Map.of(personId.toString(), personName)); + } + @EventListener(ApplicationReadyEvent.class) @Transactional public void recoverOrphanedRuns() { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java index eee17ad9..236c3d4a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/OcrControllerTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.config.SecurityConfig; import org.raddatz.familienarchiv.dto.BatchOcrDTO; import org.raddatz.familienarchiv.dto.OcrStatusDTO; +import org.raddatz.familienarchiv.dto.TrainingHistoryResponse; import org.raddatz.familienarchiv.dto.TrainingInfoResponse; import org.raddatz.familienarchiv.dto.TriggerOcrDTO; import org.raddatz.familienarchiv.exception.DomainException; @@ -290,6 +291,71 @@ class OcrControllerTest { .andExpect(jsonPath("$.senderModels[0].personId").value(personId.toString())); } + // ─── GET /api/ocr/training-info/global ─────────────────────────────────── + + @Test + void getGlobalTrainingHistory_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/ocr/training-info/global")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getGlobalTrainingHistory_returns403_whenNotAdmin() throws Exception { + mockMvc.perform(get("/api/ocr/training-info/global")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void getGlobalTrainingHistory_returns200_withRunsList() throws Exception { + TrainingHistoryResponse history = new TrainingHistoryResponse(List.of(), Map.of()); + when(ocrTrainingService.getGlobalTrainingHistory()).thenReturn(history); + + mockMvc.perform(get("/api/ocr/training-info/global")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.runs").isArray()); + } + + // ─── GET /api/ocr/training-info/{personId} ─────────────────────────────── + + @Test + void getSenderTrainingHistory_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/ocr/training-info/{id}", UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getSenderTrainingHistory_returns403_whenNotAdmin() throws Exception { + mockMvc.perform(get("/api/ocr/training-info/{id}", UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void getSenderTrainingHistory_returns404_whenPersonNotFound() throws Exception { + UUID unknownId = UUID.randomUUID(); + when(ocrTrainingService.getSenderTrainingHistory(unknownId)) + .thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found")); + + mockMvc.perform(get("/api/ocr/training-info/{id}", unknownId)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void getSenderTrainingHistory_returns200_withRunsList() throws Exception { + UUID personId = UUID.randomUUID(); + TrainingHistoryResponse history = new TrainingHistoryResponse(List.of(), Map.of(personId.toString(), "Anna Müller")); + when(ocrTrainingService.getSenderTrainingHistory(personId)).thenReturn(history); + + mockMvc.perform(get("/api/ocr/training-info/{id}", personId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.runs").isArray()) + .andExpect(jsonPath("$.personNames." + personId).value("Anna Müller")); + } + @Test @WithMockUser(authorities = "READ_ALL") void getDocumentOcrStatus_returnsNone_whenNoOcrJobExists() throws Exception { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/OcrTrainingServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrTrainingServiceTest.java index c2d8ad95..46142713 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/OcrTrainingServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/OcrTrainingServiceTest.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.dto.TrainingHistoryResponse; import org.raddatz.familienarchiv.dto.TrainingInfoResponse; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.OcrTrainingRun; @@ -312,4 +313,51 @@ class OcrTrainingServiceTest { verify(runRepository, never()).save(any()); } + + // ─── getGlobalTrainingHistory ───────────────────────────────────────────── + + @Test + void getGlobalTrainingHistory_returnsOnlyGlobalRuns() { + List globalRuns = List.of( + OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.DONE) + .blockCount(10).documentCount(2).modelName("german_kurrent").build() + ); + when(runRepository.findByPersonIdIsNullOrderByCreatedAtDesc()).thenReturn(globalRuns); + + TrainingHistoryResponse result = service.getGlobalTrainingHistory(); + + assertThat(result.runs()).hasSize(1); + assertThat(result.personNames()).isEmpty(); + verify(runRepository).findByPersonIdIsNullOrderByCreatedAtDesc(); + } + + // ─── getSenderTrainingHistory ───────────────────────────────────────────── + + @Test + void getSenderTrainingHistory_includesPersonName_andRuns() { + UUID personId = UUID.randomUUID(); + Person person = Person.builder().id(personId).firstName("Anna").lastName("Müller").build(); + when(personService.getById(personId)).thenReturn(person); + + List senderRuns = List.of( + OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.DONE) + .personId(personId).blockCount(5).documentCount(1).modelName("sender_x").build() + ); + when(runRepository.findByPersonIdOrderByCreatedAtDesc(personId)).thenReturn(senderRuns); + + TrainingHistoryResponse result = service.getSenderTrainingHistory(personId); + + assertThat(result.runs()).hasSize(1); + assertThat(result.personNames()).containsKey(personId.toString()); + } + + @Test + void getSenderTrainingHistory_propagates404_whenPersonNotFound() { + UUID unknownId = UUID.randomUUID(); + when(personService.getById(unknownId)) + .thenThrow(DomainException.notFound(org.raddatz.familienarchiv.exception.ErrorCode.PERSON_NOT_FOUND, "not found")); + + assertThatThrownBy(() -> service.getSenderTrainingHistory(unknownId)) + .isInstanceOf(DomainException.class); + } }