Repository: urlaubsverwaltung/urlaubsverwaltung (java)

Issue:
Überstundenabbau über Api abfragbar

**Is your feature request related to a problem? Please describe.**
Damit Mitarbeiter kummuliert über einen bestimmten Zeitraum wissen, wie viele Überstunden sie abgebaut haben, braucht das Tool die Anzahl an Stunden/Minuten welche in einer Abwesenheit  vom Type `Überstundenabbau`  konfiguriert wurden, in der API.

**Describe the solution you'd like**
Da aktuell die Api `/api/persons/{personId}/absences` schon benutzt wird, wäre es dufte, wenn diese Information in diese API mitgegeben wird. Es könnte z.B. in einem optionalem Feld `hours` oder `duration` eingeführt werden. Dafür könnte `java.time.Duration` verwendet werden. 

**Optionally describe alternatives you've considered**
Eine separate API in der Details zu einem bestimmten Abwesenheit abgefragt werden können. Das würde funktionieren, jedoch müsste das Tool dann für jede Abwesenheit vom Typ `Überstundenabbau` nochmal separat die Stunden/Minuten abfragen. Das ist in meinen Augen ineffizienter, aber dafür wäre die API "sauberer".



Pull request description:
Add overtime api endpoint #4547
Diese PR erweitert die API des UV um den `/api/persons/{personId}/absences/{absenceId}/overtime` Endpunkt.
Mit diesem Endpunkt kann die genutzte overtime für eine absence abgerufen werden, wie in #4547 angefragt.

Drei Punkte, die vllt. genauer betrachtet werden sollen:
* Ich habe als namen für das Feld `usedOvertimeDuration` gewählt. Ist etwas anderes besser / stimmiger? `usedOvertime` `overtimeDuration`?
* Absences in der category OVERTIME linken jetzt auf den Endpunkt mit rel:`usedOvertime`. Passt das so, oder sollte der Endpunkt gleich wie die relationship heißen?
* Der Fall `application.getHours() == null` wird vermutlich nicht vorkommen. Ist der check und code 500 trotzdem sinnvoll?

closes #4547 


Patch:
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/absence/AbsenceApiController.java b/src/main/java/org/synyx/urlaubsverwaltung/absence/AbsenceApiController.java
index 82daab6ad..2d522fb74 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/absence/AbsenceApiController.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/absence/AbsenceApiController.java
@@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.server.ResponseStatusException;
 import org.synyx.urlaubsverwaltung.api.RestControllerAdviceMarker;
+import org.synyx.urlaubsverwaltung.application.vacationtype.VacationCategory;
 import org.synyx.urlaubsverwaltung.period.DayLength;
 import org.synyx.urlaubsverwaltung.person.Person;
 import org.synyx.urlaubsverwaltung.person.PersonService;
@@ -25,6 +26,8 @@ import java.util.function.Predicate;
 import java.util.stream.Stream;
 
 import static org.springframework.hateoas.MediaTypes.HAL_JSON_VALUE;
+import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
+import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;
 import static org.springframework.http.HttpStatus.BAD_REQUEST;
 import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
 import static org.synyx.urlaubsverwaltung.absence.AbsenceDto.AbsenceType.NO_WORKDAY;
@@ -160,7 +163,14 @@ public class AbsenceApiController {
     private AbsenceDto toAbsenceDto(LocalDate date, DayLength dayLength, AbsencePeriod.RecordInfo recordInfo) {
         final AbsenceDto.AbsenceType type = toAbsenceTypes(recordInfo.getAbsenceType());
         final String status = recordInfo.getStatus().name();
-        return new AbsenceDto(date, type, recordInfo.getId().orElse(null), status, dayLength, recordInfo.getCategory().orElse(null), recordInfo.getTypeId().orElse(null));
+        final String category = recordInfo.getCategory().orElse(null);
+        AbsenceDto absenceDto = new AbsenceDto(date, type, recordInfo.getId().orElse(null), status, dayLength, category, recordInfo.getTypeId().orElse(null));
+
+        Optional<Long> absenceId = recordInfo.getId();
+        if (VacationCategory.OVERTIME.name().equals(category) && absenceId.isPresent()) {
+            absenceDto.add(linkTo(methodOn(OvertimeAbsenceApiController.class).overtimeAbsence(recordInfo.getPerson().getId(), absenceId.get())).withRel("overtime"));
+        }
+        return absenceDto;
     }
 
     private List<AbsenceDto.AbsenceType> toAbsenceTypes(List<String> dayAbsenceTypes) {
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiController.java b/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiController.java
new file mode 100644
index 000000000..2bf5988c3
--- /dev/null
+++ b/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiController.java
@@ -0,0 +1,92 @@
+package org.synyx.urlaubsverwaltung.absence;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.slf4j.Logger;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+import org.synyx.urlaubsverwaltung.api.RestControllerAdviceMarker;
+import org.synyx.urlaubsverwaltung.application.application.Application;
+import org.synyx.urlaubsverwaltung.application.application.ApplicationService;
+import org.synyx.urlaubsverwaltung.application.vacationtype.VacationCategory;
+
+import static java.lang.invoke.MethodHandles.lookup;
+import static org.slf4j.LoggerFactory.getLogger;
+import static org.springframework.hateoas.MediaTypes.HAL_JSON_VALUE;
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
+import static org.springframework.http.HttpStatus.NOT_FOUND;
+import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
+import static org.synyx.urlaubsverwaltung.absence.AbsenceApiController.ABSENCES;
+import static org.synyx.urlaubsverwaltung.security.SecurityRules.IS_BOSS_OR_OFFICE;
+
+@Tag(
+    name = "absences"
+)
+@RestControllerAdviceMarker
+@RestController
+@RequestMapping("/api/persons/{personId}")
+public class OvertimeAbsenceApiController {
+
+    private static final Logger LOG = getLogger(lookup().lookupClass());
+
+    private final ApplicationService applicationService;
+
+    @Autowired
+    public OvertimeAbsenceApiController(ApplicationService applicationService) {
+        this.applicationService = applicationService;
+    }
+
+    @Operation(
+        summary = "Returns the amount of overtime used for a specific absence of category OVERTIME",
+        description = """
+            Returns the amount of overtime used for a specific absence of category OVERTIME.
+
+            Needed basic authorities:
+            * user
+
+            Needed additional authorities:
+            * user                   - if the requested absences of the person id is from the authenticated user
+            * department_head        - if the requested absences of the person id is a managed person of the department head and not of the authenticated user
+            * second_stage_authority - if the requested absences of the person id is a managed person of the second stage authority and not of the authenticated user
+            * boss or office         - if the requested absences of the person id is any id but not of the authenticated user
+            """
+    )
+    @GetMapping(value = ABSENCES + "/{absenceId}/overtime", produces = {APPLICATION_JSON_VALUE, HAL_JSON_VALUE})
+    @PreAuthorize(IS_BOSS_OR_OFFICE +
+        " or @userApiMethodSecurity.isSamePersonId(authentication, #personId)" +
+        " or @userApiMethodSecurity.isInDepartmentOfDepartmentHead(authentication, #personId)" +
+        " or @userApiMethodSecurity.isInDepartmentOfSecondStageAuthority(authentication, #personId)")
+    public OvertimeAbsenceDto overtimeAbsence(
+        @Parameter(description = "ID of the person")
+        @PathVariable("personId")
+        Long personId,
+        @Parameter(description = "ID of the absence")
+        @PathVariable("absenceId")
+        Long absenceId) {
+        Application application = getApplicationOfPerson(personId, absenceId);
+
+        if (!application.getVacationType().isOfCategory(VacationCategory.OVERTIME)) {
+            throw new ResponseStatusException(BAD_REQUEST, "Absence does not have category OVERTIME.");
+        }
+
+        if (application.getHours() == null) {
+            LOG.error("hours duration unknown for {}", application);
+            throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Absence is of category OVERTIME, but duration is not know.");
+        }
+
+        return new OvertimeAbsenceDto(absenceId, application.getHours());
+    }
+
+    private Application getApplicationOfPerson(Long personId, Long absenceId) {
+        return applicationService.getApplicationById(absenceId)
+            .filter(application -> application.getPerson().getId().equals(personId))
+            .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "No absence for personId=" + personId + " and absenceId=" + absenceId));
+    }
+}
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceDto.java b/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceDto.java
new file mode 100644
index 000000000..dcc932b04
--- /dev/null
+++ b/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceDto.java
@@ -0,0 +1,31 @@
+package org.synyx.urlaubsverwaltung.absence;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import org.springframework.hateoas.RepresentationModel;
+
+import java.time.Duration;
+
+public class OvertimeAbsenceDto extends RepresentationModel<OvertimeAbsenceDto> {
+
+    private final Long id;
+    @JsonFormat(shape = JsonFormat.Shape.STRING)
+    @Schema(
+        example = "PT8H",
+        type = "string",
+        format = "duration")
+    private final Duration duration;
+
+    OvertimeAbsenceDto(Long id, Duration duration) {
+        this.id = id;
+        this.duration = duration;
+    }
+
+    public Long getId() {
+        return id;
+    }
+
+    public Duration getDuration() {
+        return duration;
+    }
+}


Test patch:
diff --git a/src/test/java/org/synyx/urlaubsverwaltung/absence/AbsenceApiControllerTest.java b/src/test/java/org/synyx/urlaubsverwaltung/absence/AbsenceApiControllerTest.java
index fb65d63f2..e6d8ff163 100644
--- a/src/test/java/org/synyx/urlaubsverwaltung/absence/AbsenceApiControllerTest.java
+++ b/src/test/java/org/synyx/urlaubsverwaltung/absence/AbsenceApiControllerTest.java
@@ -192,6 +192,53 @@ class AbsenceApiControllerTest {
             """, true));
     }
 
+    @Test
+    void ensureCorrectConversionOfOvertimeVacationNoon() throws Exception {
+
+        final Person person = new Person("muster", "Muster", "Marlene", "muster@example.org");
+        person.setId(23L);
+        when(personService.getPersonByID(23L)).thenReturn(Optional.of(person));
+
+        final LocalDate startDate = LocalDate.of(2016, JANUARY, 1);
+        final LocalDate endDate = LocalDate.of(2016, DECEMBER, 31);
+
+        final AbsencePeriod.RecordNoon recordNoonVacation = new AbsencePeriod.RecordNoonVacation(person, 42L, WAITING, "OVERTIME", 1L, false);
+        final AbsencePeriod.Record fullDayVacationRecord = new AbsencePeriod.Record(startDate.plusDays(1), person, recordNoonVacation);
+        final AbsencePeriod absencePeriod = new AbsencePeriod(List.of(fullDayVacationRecord));
+
+        when(absenceService.getOpenAbsences(person, startDate, endDate)).thenReturn(List.of(absencePeriod));
+
+        perform(
+            get("/api/persons/23/absences")
+                .param("from", "2016-01-01")
+                .param("to", "2016-12-31")
+        )
+            .andExpect(status().isOk())
+            .andExpect(content().contentType("application/json"))
+            .andExpect(content().json("""
+            {
+                "absences": [
+                    {
+                      "date": "2016-01-02",
+                      "id": 42,
+                      "absent": "NOON",
+                      "absentNumeric": 0.5,
+                      "absenceType": "VACATION",
+                      "category": "OVERTIME",
+                      "typeId": 1,
+                      "status": "WAITING",
+                      "links": [
+                          {
+                            "rel": "overtime",
+                            "href": "http://localhost/api/persons/23/absences/42/overtime"
+                          }
+                      ]
+                    }
+                ]
+            }
+            """, true));
+    }
+
     // SICK ------------------------------------------------------------------------------------------------------------
     @Test
     void ensureCorrectConversionOfSickFullDay() throws Exception {
diff --git a/src/test/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiControllerTest.java b/src/test/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiControllerTest.java
new file mode 100644
index 000000000..3f2c491ea
--- /dev/null
+++ b/src/test/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiControllerTest.java
@@ -0,0 +1,125 @@
+package org.synyx.urlaubsverwaltung.absence;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.synyx.urlaubsverwaltung.api.RestControllerAdviceExceptionHandler;
+import org.synyx.urlaubsverwaltung.application.application.Application;
+import org.synyx.urlaubsverwaltung.application.application.ApplicationService;
+import org.synyx.urlaubsverwaltung.application.vacationtype.VacationCategory;
+import org.synyx.urlaubsverwaltung.person.Person;
+
+import java.time.Duration;
+import java.util.Optional;
+
+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.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.synyx.urlaubsverwaltung.TestDataCreator.createVacationType;
+import static org.synyx.urlaubsverwaltung.application.vacationtype.VacationCategory.OVERTIME;
+
+@ExtendWith(MockitoExtension.class)
+class OvertimeAbsenceApiControllerTest {
+
+    private OvertimeAbsenceApiController sut;
+    @Mock
+    private ApplicationService applicationService;
+
+
+    @BeforeEach
+    void setUp() {
+        sut = new OvertimeAbsenceApiController(applicationService);
+    }
+
+    @Test
+    void threeHoursForty() throws Exception {
+        final Person person = new Person("muster", "Muster", "Marlene", "muster@example.org");
+        person.setId(2L);
+        long applicationId = 3L;
+        final Application application = new Application();
+        application.setId(applicationId);
+        application.setPerson(person);
+        application.setHours(Duration.ofHours(3).plusMinutes(40));
+        application.setVacationType(createVacationType(1L, OVERTIME, new StaticMessageSource()));
+        when(applicationService.getApplicationById(applicationId)).thenReturn(Optional.of(application));
+
+        perform(get("/api/persons/2/absences/3/overtime"))
+            .andExpect(status().isOk())
+            .andExpect(content().contentType("application/json"))
+            .andExpect(content().json("""
+                {
+                    "id": 3,
+                    "duration": "PT3H40M"
+                }
+                """));
+
+    }
+
+    @Test
+    void wrongPerson() throws Exception {
+        final Person person = new Person("muster", "Muster", "Marlene", "muster@example.org");
+        person.setId(2L);
+        long applicationId = 3L;
+        final Application application = new Application();
+        application.setId(applicationId);
+        application.setPerson(person);
+        application.setHours(Duration.ofHours(3));
+        application.setVacationType(createVacationType(1L, OVERTIME, new StaticMessageSource()));
+        when(applicationService.getApplicationById(applicationId)).thenReturn(Optional.of(application));
+
+        perform(get("/api/persons/1/absences/3/overtime"))
+            .andExpect(status().isNotFound());
+    }
+
+    @Test
+    void nonExistentApplication() throws Exception {
+        perform(get("/api/persons/2/absences/3/overtime"))
+            .andExpect(status().isNotFound());
+    }
+
+    @ParameterizedTest
+    @EnumSource(value = VacationCategory.class, names = "OVERTIME", mode = EnumSource.Mode.EXCLUDE)
+    void differentVacationCategory(final VacationCategory category) throws Exception {
+        final Person person = new Person("muster", "Muster", "Marlene", "muster@example.org");
+        person.setId(2L);
+        long applicationId = 3L;
+        final Application application = new Application();
+        application.setId(applicationId);
+        application.setPerson(person);
+        application.setHours(Duration.ofHours(3));
+        application.setVacationType(createVacationType(1L, category, new StaticMessageSource()));
+        when(applicationService.getApplicationById(applicationId)).thenReturn(Optional.of(application));
+
+        perform(get("/api/persons/2/absences/3/overtime"))
+            .andExpect(status().isBadRequest());
+    }
+
+    @Test
+    void durationNotKnown() throws Exception {
+        final Person person = new Person("muster", "Muster", "Marlene", "muster@example.org");
+        person.setId(2L);
+        long applicationId = 3L;
+        final Application application = new Application();
+        application.setId(applicationId);
+        application.setPerson(person);
+        application.setVacationType(createVacationType(1L, OVERTIME, new StaticMessageSource()));
+        when(applicationService.getApplicationById(applicationId)).thenReturn(Optional.of(application));
+
+        perform(get("/api/persons/2/absences/3/overtime"))
+            .andExpect(status().isInternalServerError());
+    }
+
+    private ResultActions perform(MockHttpServletRequestBuilder builder) throws Exception {
+        return MockMvcBuilders.standaloneSetup(sut).setControllerAdvice(new RestControllerAdviceExceptionHandler()).build().perform(builder);
+    }
+
+}