feat(ocr): add per-model history endpoints via path segments

Add findByPersonIdIsNullOrderByCreatedAtDesc + findByPersonIdOrderByCreatedAtDesc to
OcrTrainingRunRepository. Add dto/TrainingHistoryResponse. Expose
GET /api/ocr/training-info/global and GET /api/ocr/training-info/{personId} on
OcrController, both requiring ADMIN; getSenderTrainingHistory guards person existence
via PersonService and returns 404 for unknown personId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-18 00:08:38 +02:00
parent b1b7418404
commit 178afcd496
6 changed files with 154 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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<OcrTrainingRun> runs,
Map<String, String> personNames
) {}

View File

@@ -19,4 +19,8 @@ public interface OcrTrainingRunRepository extends JpaRepository<OcrTrainingRun,
boolean existsByPersonIdAndStatus(UUID personId, TrainingStatus status);
List<OcrTrainingRun> findTop20ByOrderByCreatedAtDesc();
List<OcrTrainingRun> findByPersonIdIsNullOrderByCreatedAtDesc();
List<OcrTrainingRun> findByPersonIdOrderByCreatedAtDesc(UUID personId);
}

View File

@@ -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<OcrTrainingRun> runs = trainingRunRepository.findByPersonIdIsNullOrderByCreatedAtDesc();
return new TrainingHistoryResponse(runs, Map.of());
}
public TrainingHistoryResponse getSenderTrainingHistory(UUID personId) {
String personName = personService.getById(personId).getDisplayName();
List<OcrTrainingRun> runs = trainingRunRepository.findByPersonIdOrderByCreatedAtDesc(personId);
return new TrainingHistoryResponse(runs, Map.of(personId.toString(), personName));
}
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void recoverOrphanedRuns() {

View File

@@ -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 {

View File

@@ -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<OcrTrainingRun> 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<OcrTrainingRun> 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);
}
}