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