diff --git a/dhis-2/dhis-tracker/src/main/java/org/hisp/dhis/tracker/imports/validation/validator/event/DateValidator.java b/dhis-2/dhis-tracker/src/main/java/org/hisp/dhis/tracker/imports/validation/validator/event/DateValidator.java index c8a4fb436594..631ed536cfbb 100644 --- a/dhis-2/dhis-tracker/src/main/java/org/hisp/dhis/tracker/imports/validation/validator/event/DateValidator.java +++ b/dhis-2/dhis-tracker/src/main/java/org/hisp/dhis/tracker/imports/validation/validator/event/DateValidator.java @@ -77,7 +77,7 @@ public void validate(Reporter reporter, TrackerBundle bundle, Event event) { } validateExpiryPeriodType(reporter, event, program, bundle.getUser()); validateCompletedDateIsSetOnlyForSupportedStatus(reporter, event); - validateCompletionExpiryDays(reporter, event, program, bundle.getUser()); + validateCompletionExpiryDays(reporter, preheat, event, program, bundle.getUser()); } private void validateCompletedDateIsSetOnlyForSupportedStatus(Reporter reporter, Event event) { @@ -87,19 +87,58 @@ private void validateCompletedDateIsSetOnlyForSupportedStatus(Reporter reporter, } private void validateCompletionExpiryDays( - Reporter reporter, Event event, Program program, UserDetails user) { - if (event.getCompletedAt() == null || user.isAuthorized(Authorities.F_EDIT_EXPIRED.name())) { + Reporter reporter, TrackerPreheat preheat, Event event, Program program, UserDetails user) { + if (program.getCompleteEventsExpiryDays() == 0 + || user.isAuthorized(Authorities.F_EDIT_EXPIRED.name())) { return; } - if (program.getCompleteEventsExpiryDays() > 0 - && EventStatus.COMPLETED == event.getStatus() - && now() - .isAfter(event.getCompletedAt().plus(ofDays(program.getCompleteEventsExpiryDays())))) { + Instant completedAt = getCompletedDate(preheat, event); + + if (completedAt != null + && now().isAfter(completedAt.plus(ofDays(program.getCompleteEventsExpiryDays())))) { reporter.addError(event, E1043, event); } } + /** + * Returns the completion date the expiry check should be anchored to, or {@code null} if the + * event is not completed. + * + *

When the event is already completed in the database, the persisted completion date is used. + * This makes the expiry check work even when the update payload does not include {@code + * completedAt}, and prevents the payload from resetting the expiry clock on an already completed + * event. Otherwise, when the payload itself completes the event, its completion date is used. + */ + private Instant getCompletedDate(TrackerPreheat preheat, Event event) { + Date persistedCompletedDate = getPersistedCompletedDate(preheat, event); + if (persistedCompletedDate != null) { + return persistedCompletedDate.toInstant(); + } + + if (EventStatus.COMPLETED == event.getStatus()) { + return event.getCompletedAt(); + } + + return null; + } + + private Date getPersistedCompletedDate(TrackerPreheat preheat, Event event) { + if (event instanceof TrackerEvent) { + org.hisp.dhis.tracker.model.TrackerEvent persisted = + preheat.getTrackerEvent(event.getEvent()); + return persisted != null && EventStatus.COMPLETED == persisted.getStatus() + ? persisted.getCompletedDate() + : null; + } else if (event instanceof SingleEvent) { + org.hisp.dhis.tracker.model.SingleEvent persisted = preheat.getSingleEvent(event.getEvent()); + return persisted != null && EventStatus.COMPLETED == persisted.getStatus() + ? persisted.getCompletedDate() + : null; + } + return null; + } + private void validateExpiryPeriodType( Reporter reporter, Event event, Program program, UserDetails user) { PeriodType periodType = program.getExpiryPeriodType(); diff --git a/dhis-2/dhis-tracker/src/test/java/org/hisp/dhis/tracker/imports/validation/validator/event/DateValidatorTest.java b/dhis-2/dhis-tracker/src/test/java/org/hisp/dhis/tracker/imports/validation/validator/event/DateValidatorTest.java index 4f2927726599..b4e659fcb8ce 100644 --- a/dhis-2/dhis-tracker/src/test/java/org/hisp/dhis/tracker/imports/validation/validator/event/DateValidatorTest.java +++ b/dhis-2/dhis-tracker/src/test/java/org/hisp/dhis/tracker/imports/validation/validator/event/DateValidatorTest.java @@ -43,6 +43,7 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.util.Date; import java.util.stream.Stream; import org.hisp.dhis.common.UID; import org.hisp.dhis.event.EventStatus; @@ -196,6 +197,118 @@ void shouldFailWhenTrackerEventCompletedAtIsTooSoonAndEventIsCompleted() { assertHasError(reporter, event, E1043); } + @Test + void shouldFailWhenTrackerEventCompletionHasExpiredEvenIfPayloadHasNoCompletedAt() { + when(preheat.getProgram(MetadataIdentifier.ofUid(PROGRAM_ID))).thenReturn(getProgram(5)); + UID uid = UID.generate(); + when(preheat.getTrackerEvent(uid)).thenReturn(completedTrackerEvent(sevenDaysAgo())); + Event event = + TrackerEvent.builder() + .event(uid) + .program(MetadataIdentifier.ofUid(PROGRAM_ID)) + .occurredAt(now()) + .status(EventStatus.COMPLETED) + .build(); + + validator.validate(reporter, bundle, event); + + assertHasError(reporter, event, E1043); + } + + @Test + void shouldFailWhenTrackerEventCompletionHasExpiredEvenIfPayloadHasNoStatusNorCompletedAt() { + when(preheat.getProgram(MetadataIdentifier.ofUid(PROGRAM_ID))).thenReturn(getProgram(5)); + UID uid = UID.generate(); + when(preheat.getTrackerEvent(uid)).thenReturn(completedTrackerEvent(sevenDaysAgo())); + Event event = + TrackerEvent.builder() + .event(uid) + .program(MetadataIdentifier.ofUid(PROGRAM_ID)) + .occurredAt(now()) + .build(); + + validator.validate(reporter, bundle, event); + + assertHasError(reporter, event, E1043); + } + + @Test + void shouldFailWhenSingleEventCompletionHasExpiredEvenIfPayloadHasNoCompletedAt() { + when(preheat.getProgram(MetadataIdentifier.ofUid(PROGRAM_ID))).thenReturn(getProgram(5)); + UID uid = UID.generate(); + when(preheat.getSingleEvent(uid)).thenReturn(completedSingleEvent(sevenDaysAgo())); + Event event = + SingleEvent.builder() + .event(uid) + .program(MetadataIdentifier.ofUid(PROGRAM_ID)) + .occurredAt(now()) + .status(EventStatus.COMPLETED) + .build(); + + validator.validate(reporter, bundle, event); + + assertHasError(reporter, event, E1043); + } + + @Test + void shouldUsePersistedCompletionDateAndIgnorePayloadCompletedAtWhenAlreadyCompleted() { + when(preheat.getProgram(MetadataIdentifier.ofUid(PROGRAM_ID))).thenReturn(getProgram(5)); + UID uid = UID.generate(); + // event was completed in the database long ago (expired)... + when(preheat.getTrackerEvent(uid)).thenReturn(completedTrackerEvent(sevenDaysAgo())); + Event event = + TrackerEvent.builder() + .event(uid) + .program(MetadataIdentifier.ofUid(PROGRAM_ID)) + .occurredAt(now()) + // ...so a fresh completedAt in the payload must not reset the expiry clock + .completedAt(now()) + .status(EventStatus.COMPLETED) + .build(); + + validator.validate(reporter, bundle, event); + + assertHasError(reporter, event, E1043); + } + + @Test + void shouldPassWhenTrackerEventPersistedCompletionIsWithinExpiryDays() { + when(preheat.getProgram(MetadataIdentifier.ofUid(PROGRAM_ID))).thenReturn(getProgram(5)); + UID uid = UID.generate(); + when(preheat.getTrackerEvent(uid)).thenReturn(completedTrackerEvent(twoDaysAgo())); + Event event = + TrackerEvent.builder() + .event(uid) + .program(MetadataIdentifier.ofUid(PROGRAM_ID)) + .occurredAt(now()) + .status(EventStatus.COMPLETED) + .build(); + + validator.validate(reporter, bundle, event); + + assertIsEmpty(reporter.getErrors()); + } + + @Test + void shouldPassWhenPersistedCompletionHasExpiredButUserIsAuthorized() { + when(preheat.getProgram(MetadataIdentifier.ofUid(PROGRAM_ID))).thenReturn(getProgram(5)); + UID uid = UID.generate(); + UserDetails user = mock(UserDetails.class); + when(user.isAuthorized(Authorities.F_EDIT_EXPIRED.name())).thenReturn(true); + bundle.setUser(user); + Event event = + TrackerEvent.builder() + .event(uid) + .program(MetadataIdentifier.ofUid(PROGRAM_ID)) + .occurredAt(now()) + .status(EventStatus.COMPLETED) + .build(); + + validator.validate(reporter, bundle, event); + + assertIsEmpty(reporter.getErrors()); + } + @Test void shouldFailWhenTrackerEventOccurredAtAndScheduledAtAreNotPresent() { when(preheat.getProgram(MetadataIdentifier.ofUid(PROGRAM_ID))).thenReturn(getProgram(5)); @@ -292,6 +405,22 @@ private static Stream getEvents() { return Stream.of(Arguments.of(trackerEvent), Arguments.of(singleEvent)); } + private static org.hisp.dhis.tracker.model.TrackerEvent completedTrackerEvent( + Instant completedDate) { + org.hisp.dhis.tracker.model.TrackerEvent event = new org.hisp.dhis.tracker.model.TrackerEvent(); + event.setStatus(EventStatus.COMPLETED); + event.setCompletedDate(Date.from(completedDate)); + return event; + } + + private static org.hisp.dhis.tracker.model.SingleEvent completedSingleEvent( + Instant completedDate) { + org.hisp.dhis.tracker.model.SingleEvent event = new org.hisp.dhis.tracker.model.SingleEvent(); + event.setStatus(EventStatus.COMPLETED); + event.setCompletedDate(Date.from(completedDate)); + return event; + } + private Program getProgram(int expiryDays) { Program program = createProgram('A'); program.setUid(PROGRAM_ID); @@ -309,6 +438,10 @@ private static Instant sevenDaysAgo() { return LocalDateTime.now().minusDays(7).toInstant(ZoneOffset.UTC); } + private static Instant twoDaysAgo() { + return LocalDateTime.now().minusDays(2).toInstant(ZoneOffset.UTC); + } + private Instant sevenDaysLater() { return LocalDateTime.now().plusDays(7).toInstant(ZoneOffset.UTC); }