diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/configuration/TimezoneConfiguration.java b/studymanager/src/main/java/io/redlink/more/studymanager/configuration/TimezoneConfiguration.java new file mode 100644 index 00000000..75fb75fc --- /dev/null +++ b/studymanager/src/main/java/io/redlink/more/studymanager/configuration/TimezoneConfiguration.java @@ -0,0 +1,27 @@ +package io.redlink.more.studymanager.configuration; + +import io.redlink.more.studymanager.properties.TimezoneProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.sql.Time; +import java.time.ZoneId; +import java.util.TimeZone; + +@Configuration +@EnableConfigurationProperties({TimezoneProperties.class}) +public class TimezoneConfiguration { + final TimezoneProperties properties; + + public TimezoneConfiguration(TimezoneProperties properties) { + this.properties = properties; + } + + @Bean + public ZoneId ZoneId() { + return TimeZone().toZoneId(); + } + + @Bean public TimeZone TimeZone() { return TimeZone.getTimeZone(properties.identifier()); } +} diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/Timeframe.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/Timeframe.java new file mode 100644 index 00000000..c4e82198 --- /dev/null +++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/Timeframe.java @@ -0,0 +1,8 @@ +package io.redlink.more.studymanager.model; + +import java.time.LocalDate; + +public record Timeframe ( + LocalDate from, + LocalDate to +) {} diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/Transformers.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/Transformers.java index 6e39e833..6afc0b51 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/Transformers.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/Transformers.java @@ -11,6 +11,7 @@ /** * Common basic transformers. */ + public final class Transformers { private static final ZoneId HOME = ZoneId.of("Europe/Vienna"); diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/properties/TimezoneProperties.java b/studymanager/src/main/java/io/redlink/more/studymanager/properties/TimezoneProperties.java new file mode 100644 index 00000000..fbb3ac1d --- /dev/null +++ b/studymanager/src/main/java/io/redlink/more/studymanager/properties/TimezoneProperties.java @@ -0,0 +1,8 @@ +package io.redlink.more.studymanager.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "more.timezone") +public record TimezoneProperties ( + String identifier +) {} diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/repository/StudyRepository.java b/studymanager/src/main/java/io/redlink/more/studymanager/repository/StudyRepository.java index 308f588c..d7f60f6d 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/repository/StudyRepository.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/repository/StudyRepository.java @@ -1,18 +1,18 @@ package io.redlink.more.studymanager.repository; -import io.redlink.more.studymanager.model.Contact; -import io.redlink.more.studymanager.model.Study; -import io.redlink.more.studymanager.model.StudyRole; -import io.redlink.more.studymanager.model.User; -import java.util.List; -import java.util.Optional; -import java.util.Set; +import io.redlink.more.studymanager.exception.BadRequestException; +import io.redlink.more.studymanager.model.*; +import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Component; +import java.util.List; +import java.util.Optional; +import java.util.Set; + @Component public class StudyRepository { @@ -46,6 +46,7 @@ public class StudyRepository { private static final String SET_PAUSED_STATE_BY_ID = "UPDATE studies SET status = 'paused', modified = now() WHERE study_id = ?"; private static final String SET_CLOSED_STATE_BY_ID = "UPDATE studies SET status = 'closed', end_date = now(), modified = now() WHERE study_id = ?"; private static final String STUDY_HAS_STATE = "SELECT study_id FROM studies WHERE study_id = :study_id AND status::varchar IN (:study_status)"; + private static final String GET_STUDY_TIMEFRAME = "SELECT planned_start_date, planned_end_date FROM studies WHERE study_id = ?"; private final JdbcTemplate template; private final NamedParameterJdbcTemplate namedTemplate; @@ -113,6 +114,17 @@ private String getStatusQuery(Study.Status status) { }; } + public Timeframe getStudyTimeframe(Long studyId) { + try { + return template.queryForObject( + GET_STUDY_TIMEFRAME, + getStudyTimeframeRowMapper(), + studyId); + } catch (EmptyResultDataAccessException e) { + throw new BadRequestException("Study " + studyId + " does not exist"); + } + } + private static MapSqlParameterSource studyToParams(Study study) { return new MapSqlParameterSource() .addValue("title", study.getTitle()) @@ -149,6 +161,12 @@ private static RowMapper getStudyRowMapper() { .setPhoneNumber(rs.getString("contact_phone"))); } + private static RowMapper getStudyTimeframeRowMapper() { + return (rs,rowNum) -> new Timeframe( + RepositoryUtils.readLocalDate(rs, "planned_start_date"), + RepositoryUtils.readLocalDate(rs, "planned_end_date")); + } + private static RowMapper getStudyRowMapperWithUserRoles() { return ((rs, rowNum) -> { var study = getStudyRowMapper().mapRow(rs, rowNum); diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/scheduling/SchedulingService.java b/studymanager/src/main/java/io/redlink/more/studymanager/scheduling/SchedulingService.java index 1c06b5d2..c4601dd2 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/scheduling/SchedulingService.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/scheduling/SchedulingService.java @@ -22,10 +22,12 @@ public class SchedulingService { public static final String TRIGGER = "trigger"; public static final String JOB = "job"; private final Scheduler scheduler; + private final TimeZone timeZone; - public SchedulingService(SchedulerFactoryBean factory) throws SchedulerException { + public SchedulingService(SchedulerFactoryBean factory, TimeZone timeZone) throws SchedulerException { this.scheduler = factory.getScheduler(); this.scheduler.start(); + this.timeZone = timeZone; } public String scheduleJob(String issuer, Map data, Schedule schedule, Class type) { @@ -69,7 +71,7 @@ public void preDestroy() throws SchedulerException { private ScheduleBuilder getSchedulerBuilderFor(Schedule schedule) { if(schedule instanceof CronSchedule) { return CronScheduleBuilder.cronSchedule(((CronSchedule) schedule).getCronExpression()) - .inTimeZone(TimeZone.getTimeZone("Europe/Vienna")); //TODO make configurable per study + .inTimeZone(timeZone); //TODO make configurable per study } else { throw new NotImplementedException("SchedulerType " + schedule.getClass().getSimpleName() + " not yet supportet"); } diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/InterventionService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/InterventionService.java index c838f57c..b27e2920 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/service/InterventionService.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/InterventionService.java @@ -3,11 +3,11 @@ import io.redlink.more.studymanager.core.component.Component; import io.redlink.more.studymanager.core.exception.ConfigurationValidationException; import io.redlink.more.studymanager.core.factory.ActionFactory; +import io.redlink.more.studymanager.core.factory.TriggerFactory; import io.redlink.more.studymanager.core.validation.ConfigurationValidationReport; import io.redlink.more.studymanager.exception.BadRequestException; import io.redlink.more.studymanager.exception.NotFoundException; import io.redlink.more.studymanager.model.Action; -import io.redlink.more.studymanager.core.factory.TriggerFactory; import io.redlink.more.studymanager.model.Intervention; import io.redlink.more.studymanager.model.Study; import io.redlink.more.studymanager.model.Trigger; @@ -15,9 +15,6 @@ import io.redlink.more.studymanager.repository.StudyRepository; import io.redlink.more.studymanager.sdk.MoreSDK; import io.redlink.more.studymanager.utils.LoggingUtils; - -import java.text.ParseException; - import org.quartz.CronExpression; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,6 +22,7 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; +import java.text.ParseException; import java.util.List; import java.util.Map; import java.util.Objects; diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/ObservationService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/ObservationService.java index e541db7c..0f25ddad 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/service/ObservationService.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/ObservationService.java @@ -18,16 +18,19 @@ @Service public class ObservationService { + private final ScheduleService scheduleService; private final StudyStateService studyStateService; private final ObservationRepository repository; private final Map observationFactories; private final MoreSDK sdk; - public ObservationService(StudyStateService studyStateService, + public ObservationService(ScheduleService scheduleService, + StudyStateService studyStateService, ObservationRepository repository, Map observationFactories, MoreSDK sdk) { + this.scheduleService = scheduleService; this.studyStateService = studyStateService; this.repository = repository; this.observationFactories = observationFactories; @@ -36,6 +39,7 @@ public ObservationService(StudyStateService studyStateService, public Observation addObservation(Observation observation) { studyStateService.assertStudyNotInState(observation.getStudyId(), Study.Status.CLOSED); + scheduleService.assertScheduleWithinStudyTime(observation.getStudyId(), observation.getSchedule()); return repository.insert(validate(observation)); } @@ -58,6 +62,7 @@ public List listObservations(Long studyId) { public Observation updateObservation(Observation observation) { studyStateService.assertStudyNotInState(observation.getStudyId(), Study.Status.CLOSED); + scheduleService.assertScheduleWithinStudyTime(observation.getStudyId(), observation.getSchedule()); return repository.updateObservation(validate(observation)); } diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/ScheduleService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/ScheduleService.java new file mode 100644 index 00000000..7736457e --- /dev/null +++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/ScheduleService.java @@ -0,0 +1,30 @@ +package io.redlink.more.studymanager.service; + +import io.redlink.more.studymanager.exception.BadRequestException; +import io.redlink.more.studymanager.model.Event; +import io.redlink.more.studymanager.model.Timeframe; +import io.redlink.more.studymanager.repository.StudyRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.ZoneId; + +@Service +public class ScheduleService { + private final StudyRepository studyRepository; + private final ZoneId zoneId; + + public ScheduleService(StudyRepository studyRepository, ZoneId zoneId) { + this.studyRepository = studyRepository; + this.zoneId = zoneId; + } + + public Event assertScheduleWithinStudyTime(Long studyId, Event schedule) { + Timeframe timeframe = studyRepository.getStudyTimeframe(studyId); + if(LocalDate.ofInstant(schedule.getDateStart(), zoneId).isBefore(timeframe.from()) + || LocalDate.ofInstant(schedule.getDateEnd(), zoneId).isAfter(timeframe.to())) { + throw new BadRequestException("Schedule should lie within study timeframe"); + } + return schedule; + } +} diff --git a/studymanager/src/main/resources/application.yaml b/studymanager/src/main/resources/application.yaml index 87d0ea39..770eb560 100644 --- a/studymanager/src/main/resources/application.yaml +++ b/studymanager/src/main/resources/application.yaml @@ -101,6 +101,9 @@ more: more-admin: - 'more-admin' + timezone: + identifier: "${MORE_TIMEZONE:Europe/Vienna}" + frontend: title: "${MORE_FE_TITLE:MORE Studymanager}" keycloak: diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/service/InterventionServiceTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/service/InterventionServiceTest.java index 83e60b79..6ed6dc3a 100644 --- a/studymanager/src/test/java/io/redlink/more/studymanager/service/InterventionServiceTest.java +++ b/studymanager/src/test/java/io/redlink/more/studymanager/service/InterventionServiceTest.java @@ -31,6 +31,8 @@ class InterventionServiceTest { StudyPermissionService studyPermissionService; @Mock StudyStateService studyStateService; + @Mock + ScheduleService scheduleService; @InjectMocks InterventionService interventionService; diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/service/ObservationServiceTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/service/ObservationServiceTest.java index 53ad363c..bf16e2d6 100644 --- a/studymanager/src/test/java/io/redlink/more/studymanager/service/ObservationServiceTest.java +++ b/studymanager/src/test/java/io/redlink/more/studymanager/service/ObservationServiceTest.java @@ -38,6 +38,9 @@ class ObservationServiceTest { @Mock StudyStateService studyStateService; + @Mock + ScheduleService scheduleService; + @InjectMocks ObservationService observationService; diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/service/ScheduleServiceTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/service/ScheduleServiceTest.java new file mode 100644 index 00000000..0f7dd5f6 --- /dev/null +++ b/studymanager/src/test/java/io/redlink/more/studymanager/service/ScheduleServiceTest.java @@ -0,0 +1,84 @@ +package io.redlink.more.studymanager.service; + +import io.redlink.more.studymanager.configuration.TimezoneConfiguration; +import io.redlink.more.studymanager.exception.BadRequestException; +import io.redlink.more.studymanager.model.Event; +import io.redlink.more.studymanager.model.Timeframe; +import io.redlink.more.studymanager.repository.StudyRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextConfiguration; + +import java.time.Instant; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ExtendWith(MockitoExtension.class) +@ContextConfiguration(initializers = ScheduleServiceTest.EnvInitializer.class, + classes = { + ScheduleService.class, + TimezoneConfiguration.class, + }) +public class ScheduleServiceTest { + + @MockBean + StudyRepository studyRepository; + + @Autowired + @InjectMocks + ScheduleService scheduleService; + + + @Test + void testAssertPasses() { + when(studyRepository.getStudyTimeframe(anyLong())).thenReturn( + new Timeframe( + LocalDate.of(2023, 6, 2), + LocalDate.of(2023,6,3)) + ); + Event schedule = new Event() + .setDateStart(Instant.parse("2023-06-02T00:00:00.00Z")) + .setDateEnd(Instant.parse("2023-06-03T00:00:00.00Z")); + assertThat(scheduleService.assertScheduleWithinStudyTime(1L, schedule)).isEqualTo(schedule); + } + + @Test + void testAssertFails() { + when(studyRepository.getStudyTimeframe(anyLong())).thenReturn( + new Timeframe( + LocalDate.of(2023, 6, 2), + LocalDate.of(2023,6,3)) + ); + Event scheduleBefore = new Event() + .setDateStart(Instant.parse("2023-06-01T00:00:00.00Z")) + .setDateEnd(Instant.parse("2023-06-02T00:00:00.00Z")); + Event scheduleAfter = new Event() + .setDateStart(Instant.parse("2023-06-02T00:00:00.00Z")) + .setDateEnd(Instant.parse("2023-06-04T00:00:00.00Z")); + assertThrows(BadRequestException.class, () -> scheduleService.assertScheduleWithinStudyTime(1L, scheduleBefore)); + assertThrows(BadRequestException.class, () -> scheduleService.assertScheduleWithinStudyTime(1L, scheduleAfter)); + } + + static class EnvInitializer implements ApplicationContextInitializer { + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + TestPropertyValues.of( + "more.timeframe.identifier=Europe/Vienna" + ).applyTo(applicationContext); + } + } +}