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:
@@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.raddatz.familienarchiv.dto.BatchOcrDTO;
|
import org.raddatz.familienarchiv.dto.BatchOcrDTO;
|
||||||
import org.raddatz.familienarchiv.dto.OcrStatusDTO;
|
import org.raddatz.familienarchiv.dto.OcrStatusDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.TrainingHistoryResponse;
|
||||||
import org.raddatz.familienarchiv.dto.TrainingInfoResponse;
|
import org.raddatz.familienarchiv.dto.TrainingInfoResponse;
|
||||||
import org.raddatz.familienarchiv.dto.TriggerOcrDTO;
|
import org.raddatz.familienarchiv.dto.TriggerOcrDTO;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
@@ -135,6 +136,18 @@ public class OcrController {
|
|||||||
return ocrTrainingService.getTrainingInfo();
|
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) {
|
private UUID resolveUserId(Authentication authentication) {
|
||||||
if (authentication == null || !authentication.isAuthenticated()) return null;
|
if (authentication == null || !authentication.isAuthenticated()) return null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -19,4 +19,8 @@ public interface OcrTrainingRunRepository extends JpaRepository<OcrTrainingRun,
|
|||||||
boolean existsByPersonIdAndStatus(UUID personId, TrainingStatus status);
|
boolean existsByPersonIdAndStatus(UUID personId, TrainingStatus status);
|
||||||
|
|
||||||
List<OcrTrainingRun> findTop20ByOrderByCreatedAtDesc();
|
List<OcrTrainingRun> findTop20ByOrderByCreatedAtDesc();
|
||||||
|
|
||||||
|
List<OcrTrainingRun> findByPersonIdIsNullOrderByCreatedAtDesc();
|
||||||
|
|
||||||
|
List<OcrTrainingRun> findByPersonIdOrderByCreatedAtDesc(UUID personId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.dto.TrainingHistoryResponse;
|
||||||
import org.raddatz.familienarchiv.dto.TrainingInfoResponse;
|
import org.raddatz.familienarchiv.dto.TrainingInfoResponse;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
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)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
@Transactional
|
@Transactional
|
||||||
public void recoverOrphanedRuns() {
|
public void recoverOrphanedRuns() {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
import org.raddatz.familienarchiv.dto.BatchOcrDTO;
|
import org.raddatz.familienarchiv.dto.BatchOcrDTO;
|
||||||
import org.raddatz.familienarchiv.dto.OcrStatusDTO;
|
import org.raddatz.familienarchiv.dto.OcrStatusDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.TrainingHistoryResponse;
|
||||||
import org.raddatz.familienarchiv.dto.TrainingInfoResponse;
|
import org.raddatz.familienarchiv.dto.TrainingInfoResponse;
|
||||||
import org.raddatz.familienarchiv.dto.TriggerOcrDTO;
|
import org.raddatz.familienarchiv.dto.TriggerOcrDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
@@ -290,6 +291,71 @@ class OcrControllerTest {
|
|||||||
.andExpect(jsonPath("$.senderModels[0].personId").value(personId.toString()));
|
.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
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getDocumentOcrStatus_returnsNone_whenNoOcrJobExists() throws Exception {
|
void getDocumentOcrStatus_returnsNone_whenNoOcrJobExists() throws Exception {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.dto.TrainingHistoryResponse;
|
||||||
import org.raddatz.familienarchiv.dto.TrainingInfoResponse;
|
import org.raddatz.familienarchiv.dto.TrainingInfoResponse;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.OcrTrainingRun;
|
import org.raddatz.familienarchiv.model.OcrTrainingRun;
|
||||||
@@ -312,4 +313,51 @@ class OcrTrainingServiceTest {
|
|||||||
|
|
||||||
verify(runRepository, never()).save(any());
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user