diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/config/Patchable.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/config/Patchable.kt new file mode 100644 index 0000000000..3657eddefb --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/config/Patchable.kt @@ -0,0 +1,8 @@ +package fr.gouv.cnsp.monitorfish.config + +/** + * Warning: All properties patched MUST be declared as `var`, and not `val` + */ +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class Patchable diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/entities/mission/mission_actions/MissionAction.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/entities/mission/mission_actions/MissionAction.kt index 48f4606392..c98aef8c99 100644 --- a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/entities/mission/mission_actions/MissionAction.kt +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/entities/mission/mission_actions/MissionAction.kt @@ -1,6 +1,7 @@ package fr.gouv.cnsp.monitorfish.domain.entities.mission.mission_actions import com.neovisionaries.i18n.CountryCode +import fr.gouv.cnsp.monitorfish.config.Patchable import fr.gouv.cnsp.monitorfish.domain.entities.mission.ControlUnit import java.time.ZonedDateTime @@ -17,7 +18,8 @@ data class MissionAction( val faoAreas: List = listOf(), val actionType: MissionActionType, val actionDatetimeUtc: ZonedDateTime, - val actionEndDatetimeUtc: ZonedDateTime? = null, + @Patchable + var actionEndDatetimeUtc: ZonedDateTime? = null, val emitsVms: ControlCheck? = null, val emitsAis: ControlCheck? = null, val flightGoals: List = listOf(), @@ -65,7 +67,8 @@ data class MissionAction( val isComplianceWithWaterRegulationsControl: Boolean? = null, val isSafetyEquipmentAndStandardsComplianceControl: Boolean? = null, val isSeafarersControl: Boolean? = null, - val observationsByUnit: String? = null, + @Patchable + var observationsByUnit: String? = null, ) { fun verify() { val controlTypes = listOf( diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/entities/mission/mission_actions/PatchableMissionAction.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/entities/mission/mission_actions/PatchableMissionAction.kt new file mode 100644 index 0000000000..0e5490b00d --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/entities/mission/mission_actions/PatchableMissionAction.kt @@ -0,0 +1,9 @@ +package fr.gouv.cnsp.monitorfish.domain.entities.mission.mission_actions + +import java.time.ZonedDateTime +import java.util.* + +data class PatchableMissionAction( + val actionEndDatetimeUtc: Optional?, + val observationsByUnit: Optional?, +) diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/mappers/PatchEntity.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/mappers/PatchEntity.kt new file mode 100644 index 0000000000..9010f00d6d --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/mappers/PatchEntity.kt @@ -0,0 +1,54 @@ +package fr.gouv.cnsp.monitorfish.domain.mappers + +import fr.gouv.cnsp.monitorfish.config.Patchable +import fr.gouv.cnsp.monitorfish.config.UseCase +import java.util.* +import kotlin.reflect.KMutableProperty +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.memberProperties + +@UseCase +class PatchEntity { + + /** + * Patches the target entity with values from the source entity. + * + * This function updates the target entity with values from the source entity for properties + * annotated with @Patchable. If a property in the source entity is null, the existing value + * in the target entity is retained. If a property in the source entity is an Optional, it + * is handled accordingly. + * + * @param target The target entity to be patched. + * @param source The source entity providing the patch values. + */ + fun execute(target: T, source: S): T { + val sourceProperties = source::class.memberProperties + val targetProperties = target::class.memberProperties + + for (sourceProp in sourceProperties) { + val targetProp = + targetProperties.filter { it.hasAnnotation() }.find { it.name == sourceProp.name } + if (targetProp != null && targetProp is KMutableProperty<*>) { + val sourceValue = sourceProp.getter.call(source) + val existingValue = targetProp.getter.call(target) + val finalValue = if (sourceValue is Optional<*>) { + getValueFromOptional(existingValue, sourceValue) + } else { + sourceValue ?: existingValue + } + + targetProp.setter.call(target, finalValue) + } + } + + return target + } + + private fun getValueFromOptional(existingValue: Any?, optional: Optional<*>?): Any? { + return when { + optional == null -> existingValue + optional.isPresent -> optional.get() + else -> null + } + } +} diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/mission/mission_actions/PatchMissionAction.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/mission/mission_actions/PatchMissionAction.kt new file mode 100644 index 0000000000..d759070d10 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/mission/mission_actions/PatchMissionAction.kt @@ -0,0 +1,28 @@ +package fr.gouv.cnsp.monitorfish.domain.use_cases.mission.mission_actions + +import fr.gouv.cnsp.monitorfish.config.UseCase +import fr.gouv.cnsp.monitorfish.domain.entities.mission.mission_actions.MissionAction +import fr.gouv.cnsp.monitorfish.domain.entities.mission.mission_actions.PatchableMissionAction +import fr.gouv.cnsp.monitorfish.domain.exceptions.BackendUsageErrorCode +import fr.gouv.cnsp.monitorfish.domain.exceptions.BackendUsageException +import fr.gouv.cnsp.monitorfish.domain.mappers.PatchEntity +import fr.gouv.cnsp.monitorfish.domain.repositories.MissionActionsRepository + +@UseCase +class PatchMissionAction( + private val missionActionsRepository: MissionActionsRepository, + private val patchMissionAction: PatchEntity, +) { + + fun execute(id: Int, patchableEnvActionEntity: PatchableMissionAction): MissionAction { + return try { + val previousMissionAction = missionActionsRepository.findById(id) + + val updatedMissionAction = patchMissionAction.execute(previousMissionAction, patchableEnvActionEntity) + + missionActionsRepository.save(updatedMissionAction) + } catch (e: Exception) { + throw BackendUsageException(BackendUsageErrorCode.NOT_FOUND, "Action $id not found", e) + } + } +} diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/public_api/PublicMissionActionsController.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/public_api/PublicMissionActionsController.kt index c7d98234ba..8e5727ba5b 100644 --- a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/public_api/PublicMissionActionsController.kt +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/public_api/PublicMissionActionsController.kt @@ -1,20 +1,22 @@ package fr.gouv.cnsp.monitorfish.infrastructure.api.public_api import fr.gouv.cnsp.monitorfish.domain.use_cases.mission.mission_actions.GetMissionActions +import fr.gouv.cnsp.monitorfish.domain.use_cases.mission.mission_actions.PatchMissionAction import fr.gouv.cnsp.monitorfish.infrastructure.api.outputs.MissionActionDataOutput +import fr.gouv.cnsp.monitorfish.infrastructure.api.public_api.input.PatchableMissionActionDataInput import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController +import jakarta.websocket.server.PathParam +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/mission_actions") @Tag(name = "APIs for mission actions") class PublicMissionActionsController( private val getMissionActions: GetMissionActions, + private val patchMissionAction: PatchMissionAction, ) { @GetMapping("") @@ -26,4 +28,19 @@ class PublicMissionActionsController( ): List { return getMissionActions.execute(missionId).map { MissionActionDataOutput.fromMissionAction(it) } } + + @PatchMapping(value = ["/{actionId}"], consumes = ["application/json"]) + @Operation(summary = "Update a mission action") + @ResponseStatus(HttpStatus.OK) + fun updateMissionAction( + @PathParam("Action id") + @PathVariable(name = "actionId") + actionId: Int, + @RequestBody + actionInput: PatchableMissionActionDataInput, + ): MissionActionDataOutput { + val updatedMissionAction = patchMissionAction.execute(actionId, actionInput.toPatchableMissionAction()) + + return MissionActionDataOutput.fromMissionAction(updatedMissionAction) + } } diff --git a/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/public_api/input/PatchableMissionActionDataInput.kt b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/public_api/input/PatchableMissionActionDataInput.kt new file mode 100644 index 0000000000..2828bb7b0e --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/public_api/input/PatchableMissionActionDataInput.kt @@ -0,0 +1,17 @@ +package fr.gouv.cnsp.monitorfish.infrastructure.api.public_api.input + +import fr.gouv.cnsp.monitorfish.domain.entities.mission.mission_actions.PatchableMissionAction +import java.time.ZonedDateTime +import java.util.* + +data class PatchableMissionActionDataInput( + val actionEndDatetimeUtc: Optional?, + val observationsByUnit: Optional?, +) { + fun toPatchableMissionAction(): PatchableMissionAction { + return PatchableMissionAction( + actionEndDatetimeUtc = actionEndDatetimeUtc, + observationsByUnit = observationsByUnit, + ) + } +} diff --git a/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/mission/mission_actions/PatchMissionActionUTests.kt b/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/mission/mission_actions/PatchMissionActionUTests.kt new file mode 100644 index 0000000000..3ebbf9f4c9 --- /dev/null +++ b/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/mission/mission_actions/PatchMissionActionUTests.kt @@ -0,0 +1,71 @@ +package fr.gouv.cnsp.monitorfish.domain.use_cases.mission.mission_actions + +import com.neovisionaries.i18n.CountryCode +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.given +import com.nhaarman.mockitokotlin2.verify +import fr.gouv.cnsp.monitorfish.domain.entities.mission.mission_actions.Completion +import fr.gouv.cnsp.monitorfish.domain.entities.mission.mission_actions.MissionAction +import fr.gouv.cnsp.monitorfish.domain.entities.mission.mission_actions.MissionActionType +import fr.gouv.cnsp.monitorfish.domain.entities.mission.mission_actions.PatchableMissionAction +import fr.gouv.cnsp.monitorfish.domain.mappers.PatchEntity +import fr.gouv.cnsp.monitorfish.domain.repositories.MissionActionsRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.time.ZonedDateTime +import java.util.* + +@ExtendWith(SpringExtension::class) +class PatchMissionActionUTests { + + @MockBean + private lateinit var missionActionsRepository: MissionActionsRepository + + private val patchMissionAction: PatchEntity = PatchEntity() + + @Test + fun `execute Should patch an existing action`() { + // Given + val action = MissionAction( + id = null, + vesselId = null, + missionId = 1, + actionDatetimeUtc = ZonedDateTime.now(), + portLocode = "AEFAT", + actionType = MissionActionType.LAND_CONTROL, + gearOnboard = listOf(), + seizureAndDiversion = true, + isDeleted = false, + hasSomeGearsSeized = false, + hasSomeSpeciesSeized = false, + completedBy = "XYZ", + isFromPoseidon = false, + flagState = CountryCode.FR, + userTrigram = "LTH", + completion = Completion.TO_COMPLETE, + ) + given(missionActionsRepository.findById(any())).willReturn(action) + val expectedDateTime = ZonedDateTime.now() + val patch = PatchableMissionAction( + actionEndDatetimeUtc = Optional.of(expectedDateTime), + observationsByUnit = Optional.of("An observation"), + ) + + // When + PatchMissionAction(missionActionsRepository, patchMissionAction).execute(123, patch) + + // Then + argumentCaptor().apply { + verify(missionActionsRepository).save(capture()) + + assertThat(allValues.first().observationsByUnit).isEqualTo("An observation") + assertThat(allValues.first().actionEndDatetimeUtc).isEqualTo(expectedDateTime) + assertThat(allValues.first().userTrigram).isEqualTo("LTH") + assertThat(allValues.first().actionType).isEqualTo(MissionActionType.LAND_CONTROL) + } + } +} diff --git a/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/mission/mission_actions/TestUtils.kt b/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/domain/use_cases/mission/mission_actions/TestUtils.kt deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/public_api/PublicMissionActionsControllerITests.kt b/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/public_api/PublicMissionActionsControllerITests.kt index aed5923c0e..d7b2e5dba9 100644 --- a/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/public_api/PublicMissionActionsControllerITests.kt +++ b/backend/src/test/kotlin/fr/gouv/cnsp/monitorfish/infrastructure/api/public_api/PublicMissionActionsControllerITests.kt @@ -2,11 +2,14 @@ package fr.gouv.cnsp.monitorfish.infrastructure.api.public_api import com.neovisionaries.i18n.CountryCode import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.given import fr.gouv.cnsp.monitorfish.config.SentryConfig import fr.gouv.cnsp.monitorfish.domain.entities.mission.mission_actions.Completion import fr.gouv.cnsp.monitorfish.domain.entities.mission.mission_actions.MissionAction import fr.gouv.cnsp.monitorfish.domain.entities.mission.mission_actions.MissionActionType import fr.gouv.cnsp.monitorfish.domain.use_cases.mission.mission_actions.GetMissionActions +import fr.gouv.cnsp.monitorfish.domain.use_cases.mission.mission_actions.PatchMissionAction +import fr.gouv.cnsp.monitorfish.infrastructure.database.repositories.TestUtils import kotlinx.coroutines.runBlocking import org.hamcrest.Matchers.equalTo import org.junit.jupiter.api.Test @@ -17,8 +20,10 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.context.annotation.Import +import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.time.ZonedDateTime @@ -34,6 +39,9 @@ class PublicMissionActionsControllerITests { @MockBean private lateinit var getMissionActions: GetMissionActions + @MockBean + private lateinit var patchMissionAction: PatchMissionAction + private fun givenSuspended(block: suspend () -> T) = BDDMockito.given(runBlocking { block() })!! @Test @@ -69,4 +77,28 @@ class PublicMissionActionsControllerITests { Mockito.verify(getMissionActions).execute(123) } } + + @Test + fun `Should patch a mission action`() { + // Given + val dateTime = ZonedDateTime.parse("2022-05-05T03:04:05.000Z") + val newMission = TestUtils.getDummyMissionAction(dateTime).copy(flagState = CountryCode.UNDEFINED) + given(patchMissionAction.execute(any(), any())).willReturn(newMission) + + // When + api.perform( + patch("/api/v1/mission_actions/123") + .content( + """ + { + "observationsByUnit": "OBSERVATION", + "actionEndDatetimeUtc": "2024-02-01T14:29:00Z" + } + """.trimIndent(), + ) + .contentType(MediaType.APPLICATION_JSON), + ) + // Then + .andExpect(status().isOk) + } }