From a2ddca91e2c2a2457e29819eaf1c87fb553d5a5a Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Wed, 15 Nov 2023 15:46:14 +0100 Subject: [PATCH 01/15] MORE2-5 set participants start datetime on registration --- .../transformer/StudyTransformer.java | 13 ++-- .../redlink/more/data/model/Observation.java | 4 +- .../more/data/model/SimpleParticipant.java | 5 +- .../io/redlink/more/data/model/Study.java | 2 - .../more/data/model/scheduler/Duration.java | 75 +++++++++++++++++++ .../data/model/{ => scheduler}/Event.java | 13 +++- .../model/{ => scheduler}/RecurrenceRule.java | 2 +- .../data/model/scheduler/RelativeDate.java | 28 +++++++ .../data/model/scheduler/RelativeEvent.java | 54 +++++++++++++ .../scheduler/RelativeRecurrenceRule.java | 29 +++++++ .../data/model/scheduler/ScheduleEvent.java | 18 +++++ .../redlink/more/data/repository/DbUtils.java | 6 +- .../more/data/repository/StudyRepository.java | 16 ++-- .../more/data/schedule/ICalendarParser.java | 16 ++-- .../more/data/service/ExternalService.java | 18 +++-- .../data/schedule/ICalendarParserTest.java | 26 +++---- 16 files changed, 280 insertions(+), 45 deletions(-) create mode 100644 src/main/java/io/redlink/more/data/model/scheduler/Duration.java rename src/main/java/io/redlink/more/data/model/{ => scheduler}/Event.java (74%) rename src/main/java/io/redlink/more/data/model/{ => scheduler}/RecurrenceRule.java (97%) create mode 100644 src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java create mode 100644 src/main/java/io/redlink/more/data/model/scheduler/RelativeEvent.java create mode 100644 src/main/java/io/redlink/more/data/model/scheduler/RelativeRecurrenceRule.java create mode 100644 src/main/java/io/redlink/more/data/model/scheduler/ScheduleEvent.java diff --git a/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java b/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java index c59ec70..fc6d9f8 100644 --- a/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java +++ b/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java @@ -12,6 +12,7 @@ import org.apache.commons.lang3.tuple.Pair; import java.time.Instant; +import java.time.LocalDateTime; import java.util.List; public final class StudyTransformer { @@ -30,7 +31,7 @@ public static StudyDTO toDTO(Study study) { .contact(toDTO(study.contact())) .start(study.startDate()) .end(study.endDate()) - .observations(toDTO(study.observations())) + .observations(toDTO(study.observations(), study.participant().start())) .version(BaseTransformers.toVersionTag(study.modified())) ; } @@ -53,11 +54,11 @@ public static ContactInfoDTO toDTO(Contact contact) { ; } - public static List toDTO(List observations) { - return observations.stream().map(StudyTransformer::toDTO).toList(); + public static List toDTO(List observations, LocalDateTime start) { + return observations.stream().map(o -> StudyTransformer.toDTO(o, start)).toList(); } - public static ObservationDTO toDTO(Observation observation) { + public static ObservationDTO toDTO(Observation observation, LocalDateTime start) { ObservationDTO dto = new ObservationDTO() .observationId(String.valueOf(observation.observationId())) .observationType(observation.type()) @@ -68,9 +69,9 @@ public static ObservationDTO toDTO(Observation observation) { .hidden(observation.hidden()) .noSchedule(observation.noSchedule()) ; - if(observation.observationSchedule() != null) { + if(observation.observationSchedule() != null && start != null) { dto.schedule(ICalendarParser - .parseToObservationSchedules(observation.observationSchedule()) + .parseToObservationSchedules(observation.observationSchedule(), start) .stream() .map(StudyTransformer::toObservationScheduleDTO) .toList()); diff --git a/src/main/java/io/redlink/more/data/model/Observation.java b/src/main/java/io/redlink/more/data/model/Observation.java index 0a7d039..6037555 100644 --- a/src/main/java/io/redlink/more/data/model/Observation.java +++ b/src/main/java/io/redlink/more/data/model/Observation.java @@ -1,5 +1,7 @@ package io.redlink.more.data.model; +import io.redlink.more.data.model.scheduler.ScheduleEvent; + import java.time.Instant; public record Observation( @@ -8,7 +10,7 @@ public record Observation( String type, String participantInfo, Object properties, - Event observationSchedule, + ScheduleEvent observationSchedule, Instant created, Instant modified, boolean hidden, diff --git a/src/main/java/io/redlink/more/data/model/SimpleParticipant.java b/src/main/java/io/redlink/more/data/model/SimpleParticipant.java index 60f68b4..fe1d9ed 100644 --- a/src/main/java/io/redlink/more/data/model/SimpleParticipant.java +++ b/src/main/java/io/redlink/more/data/model/SimpleParticipant.java @@ -1,7 +1,10 @@ package io.redlink.more.data.model; +import java.time.LocalDateTime; + public record SimpleParticipant( int id, - String alias + String alias, + LocalDateTime start ) { } diff --git a/src/main/java/io/redlink/more/data/model/Study.java b/src/main/java/io/redlink/more/data/model/Study.java index 9524c8b..f316018 100644 --- a/src/main/java/io/redlink/more/data/model/Study.java +++ b/src/main/java/io/redlink/more/data/model/Study.java @@ -1,7 +1,5 @@ package io.redlink.more.data.model; -import io.redlink.more.data.api.app.v1.model.StudyDTO; - import java.time.Instant; import java.time.LocalDate; import java.util.List; diff --git a/src/main/java/io/redlink/more/data/model/scheduler/Duration.java b/src/main/java/io/redlink/more/data/model/scheduler/Duration.java new file mode 100644 index 0000000..7fa6998 --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/scheduler/Duration.java @@ -0,0 +1,75 @@ +package io.redlink.more.data.model.scheduler; + +import com.fasterxml.jackson.annotation.JsonCreator; + +public class Duration { + + private Integer value; + + /** + * unit of time to offset + */ + public enum Unit { + MINUTE("MINUTE"), + + HOUR("HOUR"), + + DAY("DAY"); + + private String value; + + Unit(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } + + @JsonCreator + public static Unit fromValue(String value) { + for (Unit b : Unit.values()) { + if (b.value.equals(value)) { + return b; + } + } + throw new IllegalArgumentException("Unexpected value '" + value + "'"); + } + } + + private Unit unit; + + public Duration() { + } + + public Integer getValue() { + return value; + } + + public Duration setValue(Integer value) { + this.value = value; + return this; + } + + public Unit getUnit() { + return unit; + } + + public Duration setUnit(Unit unit) { + this.unit = unit; + return this; + } + + @Override + public String toString() { + return "Duration{" + + "offset=" + value + + ", unit=" + unit + + '}'; + } +} diff --git a/src/main/java/io/redlink/more/data/model/Event.java b/src/main/java/io/redlink/more/data/model/scheduler/Event.java similarity index 74% rename from src/main/java/io/redlink/more/data/model/Event.java rename to src/main/java/io/redlink/more/data/model/scheduler/Event.java index 14524f8..2d1be61 100644 --- a/src/main/java/io/redlink/more/data/model/Event.java +++ b/src/main/java/io/redlink/more/data/model/scheduler/Event.java @@ -1,12 +1,19 @@ -package io.redlink.more.data.model; +package io.redlink.more.data.model.scheduler; import java.time.Instant; -public class Event { +public class Event implements ScheduleEvent { + public static final String TYPE = "Event"; + private String type; private Instant dateStart; private Instant dateEnd; private RecurrenceRule recurrenceRule; + @Override + public String getType() { + return TYPE; + } + public Instant getDateStart() { return dateStart; } @@ -33,4 +40,6 @@ public Event setRRule(RecurrenceRule recurrenceRule) { this.recurrenceRule = recurrenceRule; return this; } + + } diff --git a/src/main/java/io/redlink/more/data/model/RecurrenceRule.java b/src/main/java/io/redlink/more/data/model/scheduler/RecurrenceRule.java similarity index 97% rename from src/main/java/io/redlink/more/data/model/RecurrenceRule.java rename to src/main/java/io/redlink/more/data/model/scheduler/RecurrenceRule.java index 80a4093..25122bd 100644 --- a/src/main/java/io/redlink/more/data/model/RecurrenceRule.java +++ b/src/main/java/io/redlink/more/data/model/scheduler/RecurrenceRule.java @@ -1,4 +1,4 @@ -package io.redlink.more.data.model; +package io.redlink.more.data.model.scheduler; import java.time.Instant; import java.util.List; diff --git a/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java b/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java new file mode 100644 index 0000000..54811d2 --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java @@ -0,0 +1,28 @@ +package io.redlink.more.data.model.scheduler; + +public class RelativeDate { + + private Duration offset; + private String time; + + public RelativeDate() { + } + + public Duration getOffset() { + return offset; + } + + public RelativeDate setOffset(Duration offset) { + this.offset = offset; + return this; + } + + public String getTime() { + return time; + } + + public RelativeDate setTime(String time) { + this.time = time; + return this; + } +} diff --git a/src/main/java/io/redlink/more/data/model/scheduler/RelativeEvent.java b/src/main/java/io/redlink/more/data/model/scheduler/RelativeEvent.java new file mode 100644 index 0000000..2d6302f --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/scheduler/RelativeEvent.java @@ -0,0 +1,54 @@ +package io.redlink.more.data.model.scheduler; + +public class RelativeEvent implements ScheduleEvent { + + public static final String TYPE = "RelativeEvent"; + + private String type; + + private RelativeDate dtstart; + + private RelativeDate dtend; + + private RelativeRecurrenceRule rrrule; + + public RelativeEvent() { + } + + @Override + public String getType() { + return TYPE; + } + + public RelativeEvent setType(String type) { + this.type = type; + return this; + } + + public RelativeDate getDtstart() { + return dtstart; + } + + public RelativeEvent setDtstart(RelativeDate dtstart) { + this.dtstart = dtstart; + return this; + } + + public RelativeDate getDtend() { + return dtend; + } + + public RelativeEvent setDtend(RelativeDate dtend) { + this.dtend = dtend; + return this; + } + + public RelativeRecurrenceRule getRrrule() { + return rrrule; + } + + public RelativeEvent setRrrule(RelativeRecurrenceRule rrrule) { + this.rrrule = rrrule; + return this; + } +} diff --git a/src/main/java/io/redlink/more/data/model/scheduler/RelativeRecurrenceRule.java b/src/main/java/io/redlink/more/data/model/scheduler/RelativeRecurrenceRule.java new file mode 100644 index 0000000..f98726c --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/scheduler/RelativeRecurrenceRule.java @@ -0,0 +1,29 @@ +package io.redlink.more.data.model.scheduler; + +public class RelativeRecurrenceRule { + + private Duration frequency; + + private Duration endAfter; + + public RelativeRecurrenceRule() { + } + + public Duration getFrequency() { + return frequency; + } + + public RelativeRecurrenceRule setFrequency(Duration frequency) { + this.frequency = frequency; + return this; + } + + public Duration getEndAfter() { + return endAfter; + } + + public RelativeRecurrenceRule setEndAfter(Duration endAfter) { + this.endAfter = endAfter; + return this; + } +} diff --git a/src/main/java/io/redlink/more/data/model/scheduler/ScheduleEvent.java b/src/main/java/io/redlink/more/data/model/scheduler/ScheduleEvent.java new file mode 100644 index 0000000..22a971c --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/scheduler/ScheduleEvent.java @@ -0,0 +1,18 @@ +package io.redlink.more.data.model.scheduler; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonIgnoreProperties( + value = "type", // ignore manually set type, it will be automatically generated by Jackson during serialization + allowSetters = true // allows the type to be set during deserialization +) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type", visible = true, defaultImpl = Event.class) +@JsonSubTypes({ + @JsonSubTypes.Type(value = Event.class, name = Event.TYPE), + @JsonSubTypes.Type(value = RelativeEvent.class, name = RelativeEvent.TYPE) +}) +public interface ScheduleEvent { + public String getType(); +} diff --git a/src/main/java/io/redlink/more/data/repository/DbUtils.java b/src/main/java/io/redlink/more/data/repository/DbUtils.java index 65bee7c..c8136fd 100644 --- a/src/main/java/io/redlink/more/data/repository/DbUtils.java +++ b/src/main/java/io/redlink/more/data/repository/DbUtils.java @@ -7,7 +7,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import io.redlink.more.data.model.Event; +import io.redlink.more.data.model.scheduler.ScheduleEvent; import java.sql.*; import java.time.Instant; @@ -50,11 +50,11 @@ public static OptionalInt readOptionalInt(ResultSet row, String columnLabel) thr } } - public static Event readEvent(ResultSet row, String columnLabel) throws SQLException { + public static ScheduleEvent readEvent(ResultSet row, String columnLabel) throws SQLException { var rawValue = row.getString(columnLabel); if(rawValue == null) return null; try { - return MAPPER.readValue(rawValue, Event.class); + return MAPPER.readValue(rawValue, ScheduleEvent.class); } catch (JsonProcessingException e) { throw new SQLDataException("Could not read Event from column '" + columnLabel + "'", e); } diff --git a/src/main/java/io/redlink/more/data/repository/StudyRepository.java b/src/main/java/io/redlink/more/data/repository/StudyRepository.java index 980f09a..9379b42 100644 --- a/src/main/java/io/redlink/more/data/repository/StudyRepository.java +++ b/src/main/java/io/redlink/more/data/repository/StudyRepository.java @@ -7,10 +7,14 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.OptionalInt; import java.util.function.Supplier; + +import io.redlink.more.data.model.scheduler.ScheduleEvent; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -72,7 +76,7 @@ public class StudyRepository { "ON CONFLICT (study_id, participant_id, observation_id) DO NOTHING"; private static final String SQL_SET_PARTICIPANT_STATUS = "UPDATE participants " + - "SET status = :newStatus::participant_status, modified = now() " + + "SET status = :newStatus::participant_status, start = :start, modified = now() " + "WHERE study_id = :study_id AND participant_id = :participant_id AND status = :oldStatus::participant_status"; private static final String GET_OBSERVATION_PROPERTIES_FOR_PARTICIPANT = @@ -124,7 +128,7 @@ public Optional getParticipantStudyGroupId(Long studyId, Integer pa } } - public Optional getObservationSchedule(Long studyId, Integer observationId) { + public Optional getObservationSchedule(Long studyId, Integer observationId) { try (var stream = jdbcTemplate.queryForStream( GET_OBSERVATION_SCHEDULE, getObservationScheduleRowMapper(), @@ -154,7 +158,8 @@ public Optional findParticipant(RoutingInfo routingInfo) { try (var stream = jdbcTemplate.queryForStream(SQL_FIND_PARTICIPANT_BY_STUDY_AND_ID, (rs, rowNum) -> new SimpleParticipant( rs.getInt("participant_id"), - rs.getString("alias") + rs.getString("alias"), + Optional.ofNullable(rs.getTimestamp("start")).map(Timestamp::toLocalDateTime).orElse(null) ) , routingInfo.studyId(), routingInfo.participantId())) { return stream.findFirst(); @@ -191,7 +196,7 @@ private static RowMapper getParticipantObservationPropertiesRowMapper() return (rs, rowNum) -> DbUtils.readObject(rs,"properties"); } - private static RowMapper getObservationScheduleRowMapper() { + private static RowMapper getObservationScheduleRowMapper() { return (rs, rowNum) -> DbUtils.readEvent(rs, "schedule"); } @@ -203,7 +208,7 @@ public Optional createCredentials(String registrationToken, ParticipantC var routingInfo = ri.get(); final String secret = passwordSupplier.get(); - storeConsent(routingInfo.studyId(), routingInfo.participantId(), consent); + //storeConsent(routingInfo.studyId(), routingInfo.participantId(), consent); final String apiId = namedTemplate.queryForObject(SQL_INSERT_CREDENTIALS, toParameterSource(routingInfo.studyId(), routingInfo.participantId()) @@ -221,6 +226,7 @@ public Optional createCredentials(String registrationToken, ParticipantC private void updateParticipantStatus(long studyId, int particpantId, String oldStatus, String newStatus) { namedTemplate.update(SQL_SET_PARTICIPANT_STATUS, toParameterSource(studyId, particpantId) + .addValue("start", "active".equals(newStatus) ? Timestamp.valueOf(LocalDateTime.now()) : null) .addValue("oldStatus", oldStatus) .addValue("newStatus", newStatus) ); diff --git a/src/main/java/io/redlink/more/data/schedule/ICalendarParser.java b/src/main/java/io/redlink/more/data/schedule/ICalendarParser.java index c94dbe1..97c952e 100644 --- a/src/main/java/io/redlink/more/data/schedule/ICalendarParser.java +++ b/src/main/java/io/redlink/more/data/schedule/ICalendarParser.java @@ -5,13 +5,15 @@ import biweekly.util.Frequency; import biweekly.util.Recurrence; import biweekly.util.com.google.ical.compat.javautil.DateIterator; -import io.redlink.more.data.model.Event; -import io.redlink.more.data.model.RecurrenceRule; +import io.redlink.more.data.model.scheduler.Event; +import io.redlink.more.data.model.scheduler.RecurrenceRule; +import io.redlink.more.data.model.scheduler.ScheduleEvent; import org.apache.commons.lang3.tuple.Pair; import java.sql.Date; import java.time.Duration; import java.time.Instant; +import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @@ -20,16 +22,18 @@ public class ICalendarParser { - public static List> parseToObservationSchedules(Event event) { + public static List> parseToObservationSchedules(ScheduleEvent scheduleEvent, LocalDateTime start) { + //TODO implement + Event event = (Event) scheduleEvent; List> observationSchedules = new ArrayList<>(); if(event.getDateStart() != null && event.getDateEnd() != null) { VEvent iCalEvent = parseToICalEvent(event); long eventDuration = getEventTime(event); DateIterator it = iCalEvent.getDateIterator(TimeZone.getDefault()); while (it.hasNext()) { - Instant start = it.next().toInstant(); - Instant end = start.plus(eventDuration, ChronoUnit.SECONDS); - observationSchedules.add(Pair.of(start, end)); + Instant ostart = it.next().toInstant(); + Instant oend = ostart.plus(eventDuration, ChronoUnit.SECONDS); + observationSchedules.add(Pair.of(ostart, oend)); } } // TODO edge cases if calculated days are not consecutive (e.g. first weekend -> first of month is a sunday) diff --git a/src/main/java/io/redlink/more/data/service/ExternalService.java b/src/main/java/io/redlink/more/data/service/ExternalService.java index dcf0a53..2540b5e 100644 --- a/src/main/java/io/redlink/more/data/service/ExternalService.java +++ b/src/main/java/io/redlink/more/data/service/ExternalService.java @@ -3,13 +3,13 @@ import io.redlink.more.data.exception.BadRequestException; import io.redlink.more.data.exception.NotFoundException; import io.redlink.more.data.model.ApiRoutingInfo; -import io.redlink.more.data.model.Event; +import io.redlink.more.data.model.scheduler.Event; +import io.redlink.more.data.model.scheduler.ScheduleEvent; import io.redlink.more.data.repository.StudyRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.time.Instant; -import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; import java.util.OptionalInt; @@ -47,12 +47,20 @@ public ApiRoutingInfo validateRoutingInfo(ApiRoutingInfo routingInfo, Integer pa } public void validateTimeFrame(Long studyId, Integer observationId, List timestamps) { - Optional schedule = repository.getObservationSchedule(studyId, observationId); + Optional schedule = repository.getObservationSchedule(studyId, observationId); if(schedule.isEmpty()){ throw NotFoundException.Observation(observationId); } - Instant startDate = schedule.get().getDateStart(); - Instant endDate = schedule.get().getDateEnd(); + + //TODO implement and cache because of inefficiency + if(!Event.class.isAssignableFrom(schedule.get().getClass())) { + throw new RuntimeException("Schedule type currently not supported"); + } + + Event event = (Event) schedule.get(); + + Instant startDate = event.getDateStart(); + Instant endDate = event.getDateEnd(); timestamps.forEach(timestamp -> { if(timestamp.isBefore(startDate) || timestamp.isAfter(endDate)) diff --git a/src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java b/src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java index 140d95e..6ec4d26 100644 --- a/src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java +++ b/src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java @@ -1,7 +1,7 @@ package io.redlink.more.data.schedule; -import io.redlink.more.data.model.Event; -import io.redlink.more.data.model.RecurrenceRule; +import io.redlink.more.data.model.scheduler.Event; +import io.redlink.more.data.model.scheduler.RecurrenceRule; import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -42,7 +42,7 @@ void testParseDailyEvent() { .setFreq("DAILY") .setInterval(1) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(eventCount); + List> actualValues = ICalendarParser.parseToObservationSchedules(eventCount, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); @@ -54,7 +54,7 @@ void testParseDailyEvent() { .setInterval(1) .setUntil(LocalDateTime.parse("2022-11-25 14:00:00", formatter).toInstant(ZoneOffset.UTC))); - actualValues = ICalendarParser.parseToObservationSchedules(eventUntil); + actualValues = ICalendarParser.parseToObservationSchedules(eventUntil, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -77,7 +77,7 @@ void testParseDailyEventWith30MinDuration() { .setFreq("DAILY") .setInterval(1) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(eventCount); + List> actualValues = ICalendarParser.parseToObservationSchedules(eventCount, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); @@ -89,7 +89,7 @@ void testParseDailyEventWith30MinDuration() { .setInterval(1) .setUntil(LocalDateTime.parse("2022-11-25 14:00:00", formatter).toInstant(ZoneOffset.UTC))); - actualValues = ICalendarParser.parseToObservationSchedules(eventUntil); + actualValues = ICalendarParser.parseToObservationSchedules(eventUntil, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @Test @@ -114,7 +114,7 @@ void testParseMonthlyEvent() { .setBySetPos(1) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -153,7 +153,7 @@ void testParseMonthlyEventByDays() { .setByDay(List.of(new String[]{"MO", "TU", "WE"})) .setBySetPos(1) .setCount(9)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -177,7 +177,7 @@ void testParseWeeklyEvent() { .setInterval(1) .setByDay(List.of(new String[]{"WE"})) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -202,7 +202,7 @@ void testParseYearlyEvent() { .setByMonthDay(5) .setByMonth(12) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -228,7 +228,7 @@ void testParseYearlyEventBySetPos() { .setByDay(List.of(new String[]{"MO"})) .setByMonth(12) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -262,7 +262,7 @@ void testParseYearlyEventBySetPosAndByDays() { .setByDay(List.of(new String[]{"MO", "TU"})) .setByMonth(12) .setCount(6)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -286,7 +286,7 @@ void testParseHourlyEvent() { .setFreq("HOURLY") .setInterval(2) .setUntil(LocalDateTime.parse("2022-12-05 20:00:00", formatter).toInstant(ZoneOffset.UTC))); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } From b28a269d6e7d468ccd48f24fde0aacca40a2a7aa Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Thu, 16 Nov 2023 15:00:26 +0100 Subject: [PATCH 02/15] MORE2-5 enable relative events on all places --- pom.xml | 6 +++ .../configuration/CachingConfiguration.java | 31 +++++++++++ .../ExternalDataApiV1Controller.java | 14 +++-- .../transformer/StudyTransformer.java | 14 ++--- .../more/data/model/SimpleParticipant.java | 5 +- .../more/data/model/scheduler/Duration.java | 16 ++++++ .../more/data/model/scheduler/Interval.java | 25 +++++++++ .../redlink/more/data/repository/DbUtils.java | 11 ++++ .../more/data/repository/StudyRepository.java | 52 +++++++++++++++---- ...alendarParser.java => SchedulerUtils.java} | 27 +++++++--- .../more/data/service/ExternalService.java | 38 ++++++-------- ...arserTest.java => SchedulerUtilsTest.java} | 24 ++++----- 12 files changed, 199 insertions(+), 64 deletions(-) create mode 100644 src/main/java/io/redlink/more/data/configuration/CachingConfiguration.java create mode 100644 src/main/java/io/redlink/more/data/model/scheduler/Interval.java rename src/main/java/io/redlink/more/data/schedule/{ICalendarParser.java => SchedulerUtils.java} (81%) rename src/test/java/io/redlink/more/data/schedule/{ICalendarParserTest.java => SchedulerUtilsTest.java} (92%) diff --git a/pom.xml b/pom.xml index 7dbf408..8b76b4b 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,12 @@ spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-cache + + org.springframework.boot diff --git a/src/main/java/io/redlink/more/data/configuration/CachingConfiguration.java b/src/main/java/io/redlink/more/data/configuration/CachingConfiguration.java new file mode 100644 index 0000000..6168e7b --- /dev/null +++ b/src/main/java/io/redlink/more/data/configuration/CachingConfiguration.java @@ -0,0 +1,31 @@ +package io.redlink.more.data.configuration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; + +@Configuration +@EnableCaching +@EnableScheduling +public class CachingConfiguration { + + public static final String OBSERVATION_ENDINGS = "observationEndings"; + private static final Logger LOGGER = LoggerFactory.getLogger(CachingConfiguration.class); + @Bean + public CacheManager cacheManager() { + return new ConcurrentMapCacheManager(OBSERVATION_ENDINGS); + } + + @CacheEvict(allEntries = true, value = {OBSERVATION_ENDINGS}) + @Scheduled(fixedDelay = 60 * 60 * 1000 , initialDelay = 5000) + public void reportCacheEvict() { + LOGGER.info("Flush Cache"); + } +} diff --git a/src/main/java/io/redlink/more/data/controller/ExternalDataApiV1Controller.java b/src/main/java/io/redlink/more/data/controller/ExternalDataApiV1Controller.java index 7609795..9c3ae7b 100644 --- a/src/main/java/io/redlink/more/data/controller/ExternalDataApiV1Controller.java +++ b/src/main/java/io/redlink/more/data/controller/ExternalDataApiV1Controller.java @@ -4,8 +4,10 @@ import io.redlink.more.data.api.app.v1.model.ExternalDataDTO; import io.redlink.more.data.api.app.v1.webservices.ExternalDataApi; import io.redlink.more.data.controller.transformer.DataTransformer; +import io.redlink.more.data.exception.BadRequestException; import io.redlink.more.data.model.ApiRoutingInfo; import io.redlink.more.data.model.RoutingInfo; +import io.redlink.more.data.model.scheduler.Interval; import io.redlink.more.data.service.ElasticService; import io.redlink.more.data.service.ExternalService; import io.redlink.more.data.util.LoggingUtils; @@ -55,10 +57,14 @@ public ResponseEntity storeExternalBulk(String moreApiToken, EndpointDataB throw new AccessDeniedException("Invalid token"); } - externalService.validateTimeFrame(studyId, observationId, - endpointDataBulkDTO.getDataPoints().stream().map(datapoint -> - datapoint.getTimestamp().toInstant() - ).toList()); + Interval interval = externalService.getIntervalForObservation(studyId, observationId, participantId); + + endpointDataBulkDTO.getDataPoints().stream() + .map(datapoint -> datapoint.getTimestamp().toInstant()) + .map(timestamp -> timestamp.isBefore(interval.getStart()) || timestamp.isAfter(interval.getEnd())) + .filter(v -> v) + .findFirst() + .orElseThrow(BadRequestException::TimeFrame); final RoutingInfo routingInfo = new RoutingInfo( externalService.validateRoutingInfo(apiRoutingInfo.get(), participantId), diff --git a/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java b/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java index fc6d9f8..1aa5979 100644 --- a/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java +++ b/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java @@ -8,7 +8,7 @@ import io.redlink.more.data.model.Observation; import io.redlink.more.data.model.SimpleParticipant; import io.redlink.more.data.model.Study; -import io.redlink.more.data.schedule.ICalendarParser; +import io.redlink.more.data.schedule.SchedulerUtils; import org.apache.commons.lang3.tuple.Pair; import java.time.Instant; @@ -31,7 +31,7 @@ public static StudyDTO toDTO(Study study) { .contact(toDTO(study.contact())) .start(study.startDate()) .end(study.endDate()) - .observations(toDTO(study.observations(), study.participant().start())) + .observations(toDTO(study.observations(), study.participant().start(), study.participant().end())) .version(BaseTransformers.toVersionTag(study.modified())) ; } @@ -54,11 +54,11 @@ public static ContactInfoDTO toDTO(Contact contact) { ; } - public static List toDTO(List observations, LocalDateTime start) { - return observations.stream().map(o -> StudyTransformer.toDTO(o, start)).toList(); + public static List toDTO(List observations, Instant start, Instant end) { + return observations.stream().map(o -> StudyTransformer.toDTO(o, start, end)).toList(); } - public static ObservationDTO toDTO(Observation observation, LocalDateTime start) { + public static ObservationDTO toDTO(Observation observation, Instant start, Instant end) { ObservationDTO dto = new ObservationDTO() .observationId(String.valueOf(observation.observationId())) .observationType(observation.type()) @@ -70,8 +70,8 @@ public static ObservationDTO toDTO(Observation observation, LocalDateTime start) .noSchedule(observation.noSchedule()) ; if(observation.observationSchedule() != null && start != null) { - dto.schedule(ICalendarParser - .parseToObservationSchedules(observation.observationSchedule(), start) + dto.schedule(SchedulerUtils + .parseToObservationSchedules(observation.observationSchedule(), start, end) .stream() .map(StudyTransformer::toObservationScheduleDTO) .toList()); diff --git a/src/main/java/io/redlink/more/data/model/SimpleParticipant.java b/src/main/java/io/redlink/more/data/model/SimpleParticipant.java index fe1d9ed..a96eb04 100644 --- a/src/main/java/io/redlink/more/data/model/SimpleParticipant.java +++ b/src/main/java/io/redlink/more/data/model/SimpleParticipant.java @@ -1,10 +1,11 @@ package io.redlink.more.data.model; -import java.time.LocalDateTime; +import java.time.Instant; public record SimpleParticipant( int id, String alias, - LocalDateTime start + Instant start, + Instant end ) { } diff --git a/src/main/java/io/redlink/more/data/model/scheduler/Duration.java b/src/main/java/io/redlink/more/data/model/scheduler/Duration.java index 7fa6998..561d2f7 100644 --- a/src/main/java/io/redlink/more/data/model/scheduler/Duration.java +++ b/src/main/java/io/redlink/more/data/model/scheduler/Duration.java @@ -2,10 +2,22 @@ import com.fasterxml.jackson.annotation.JsonCreator; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; + public class Duration { private Integer value; + public Instant getEnd(Instant start) { + if(start == null) { + return null; + } + return start.plus(value, unit.toTemporalUnit()); + } + /** * unit of time to offset */ @@ -31,6 +43,10 @@ public String toString() { return String.valueOf(value); } + public TemporalUnit toTemporalUnit() { + return ChronoUnit.valueOf(value); + } + @JsonCreator public static Unit fromValue(String value) { for (Unit b : Unit.values()) { diff --git a/src/main/java/io/redlink/more/data/model/scheduler/Interval.java b/src/main/java/io/redlink/more/data/model/scheduler/Interval.java new file mode 100644 index 0000000..3a9cf7a --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/scheduler/Interval.java @@ -0,0 +1,25 @@ +package io.redlink.more.data.model.scheduler; + +import java.time.Instant; + +public class Interval { + private Instant start; + private Instant end; + + public Interval(Instant start, Instant end) { + this.start = start; + this.end = end; + } + + public static Interval from(Event event) { + return new Interval(event.getDateStart(), event.getDateEnd()); + } + + public Instant getStart() { + return start; + } + + public Instant getEnd() { + return end; + } +} diff --git a/src/main/java/io/redlink/more/data/repository/DbUtils.java b/src/main/java/io/redlink/more/data/repository/DbUtils.java index c8136fd..e5afc24 100644 --- a/src/main/java/io/redlink/more/data/repository/DbUtils.java +++ b/src/main/java/io/redlink/more/data/repository/DbUtils.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.redlink.more.data.model.scheduler.Duration; import io.redlink.more.data.model.scheduler.ScheduleEvent; import java.sql.*; @@ -60,6 +61,16 @@ public static ScheduleEvent readEvent(ResultSet row, String columnLabel) throws } } + public static Duration readDuration(ResultSet row, String columnLabel) throws SQLException { + var rawValue = row.getString(columnLabel); + if(rawValue == null) return null; + try { + return MAPPER.readValue(rawValue, Duration.class); + } catch (JsonProcessingException e) { + throw new SQLDataException("Could not read Duration from column '" + columnLabel + "'", e); + } + } + public static Object readObject(ResultSet row, String columnLabel) throws SQLException { var rawValue = row.getString(columnLabel); if(rawValue == null) return null; diff --git a/src/main/java/io/redlink/more/data/repository/StudyRepository.java b/src/main/java/io/redlink/more/data/repository/StudyRepository.java index 9379b42..da65850 100644 --- a/src/main/java/io/redlink/more/data/repository/StudyRepository.java +++ b/src/main/java/io/redlink/more/data/repository/StudyRepository.java @@ -8,13 +8,18 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; +import java.time.Instant; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.OptionalInt; import java.util.function.Supplier; +import io.redlink.more.data.model.scheduler.Duration; +import io.redlink.more.data.model.scheduler.Interval; +import io.redlink.more.data.model.scheduler.RelativeEvent; import io.redlink.more.data.model.scheduler.ScheduleEvent; +import io.redlink.more.data.schedule.SchedulerUtils; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -33,9 +38,6 @@ public class StudyRepository { private static final String SQL_FIND_STUDY_BY_ID = "SELECT * FROM studies WHERE study_id = ?"; - private static final String SQL_FIND_PARTICIPANT_BY_STUDY_AND_ID = - "SELECT * FROM participants WHERE study_id = ? AND participant_id = ?"; - private static final String SQL_LIST_OBSERVATIONS_BY_STUDY = "SELECT * FROM observations WHERE study_id = ? AND ( study_group_id IS NULL OR study_group_id = ? )"; @@ -93,6 +95,12 @@ public class StudyRepository { private static final String GET_OBSERVATION_SCHEDULE = "SELECT schedule FROM observations WHERE study_id = ? AND observation_id = ?"; + private static final String GET_PARTICIPANT_INFO_AND_START_DURATION_END_FOR_STUDY_AND_PARTICIPANT = + "SELECT start, participant_id, alias, COALESCE(sg.duration, s.duration) AS duration, s.planned_end_date FROM participants p " + + "LEFT OUTER JOIN study_groups sg on p.study_id = sg.study_id and p.study_group_id = sg.study_group_id " + + "JOIN studies s on p.study_id = s.study_id " + + "WHERE p.study_id = ? AND participant_id = ?"; + private final JdbcTemplate jdbcTemplate; private final NamedParameterJdbcTemplate namedTemplate; @@ -155,12 +163,20 @@ public Optional findStudy(RoutingInfo routingInfo) { } public Optional findParticipant(RoutingInfo routingInfo) { - try (var stream = jdbcTemplate.queryForStream(SQL_FIND_PARTICIPANT_BY_STUDY_AND_ID, - (rs, rowNum) -> new SimpleParticipant( - rs.getInt("participant_id"), - rs.getString("alias"), - Optional.ofNullable(rs.getTimestamp("start")).map(Timestamp::toLocalDateTime).orElse(null) - ) + try (var stream = jdbcTemplate.queryForStream(GET_PARTICIPANT_INFO_AND_START_DURATION_END_FOR_STUDY_AND_PARTICIPANT, + (rs, rowNum) -> { + Instant start = Optional.ofNullable(rs.getTimestamp("start")) + .map(Timestamp::toInstant).orElse(null); + Instant end = Optional.ofNullable(DbUtils.readDuration(rs, "duration")) + .map(d -> d.getEnd(start)) + .orElse(Instant.ofEpochMilli(rs.getDate("endDate").getTime())); + return new SimpleParticipant( + rs.getInt("participant_id"), + rs.getString("alias"), + start, + end + ); + } , routingInfo.studyId(), routingInfo.participantId())) { return stream.findFirst(); } @@ -346,4 +362,22 @@ private static MapSqlParameterSource toParameterSource(long studyId, int partici .addValue("observation_id", consent.observationId()) ; } + + public Interval getInterval(Long studyId, Integer participantId, RelativeEvent event) { + try(var stream = jdbcTemplate.queryForStream( + GET_PARTICIPANT_INFO_AND_START_DURATION_END_FOR_STUDY_AND_PARTICIPANT, + ((rs, rowNum) -> { + Instant start = rs.getTimestamp("start").toInstant(); + // TODO correct sql.Date to Instant with Time 0 ?! + Instant end = Optional.ofNullable(DbUtils.readDuration(rs, "duration")) + .map(d -> d.getEnd(start)) + .orElse(Instant.ofEpochMilli(rs.getDate("endDate").getTime())); + return new Interval(start, SchedulerUtils.getEnd(event, start, end)); + + }), + studyId, participantId + )) { + return stream.findFirst().orElse(null); + } + } } diff --git a/src/main/java/io/redlink/more/data/schedule/ICalendarParser.java b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java similarity index 81% rename from src/main/java/io/redlink/more/data/schedule/ICalendarParser.java rename to src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java index 97c952e..bbc8da2 100644 --- a/src/main/java/io/redlink/more/data/schedule/ICalendarParser.java +++ b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java @@ -5,26 +5,31 @@ import biweekly.util.Frequency; import biweekly.util.Recurrence; import biweekly.util.com.google.ical.compat.javautil.DateIterator; -import io.redlink.more.data.model.scheduler.Event; -import io.redlink.more.data.model.scheduler.RecurrenceRule; -import io.redlink.more.data.model.scheduler.ScheduleEvent; +import io.redlink.more.data.model.scheduler.*; import org.apache.commons.lang3.tuple.Pair; import java.sql.Date; import java.time.Duration; import java.time.Instant; -import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.TimeZone; -public class ICalendarParser { +public class SchedulerUtils { - public static List> parseToObservationSchedules(ScheduleEvent scheduleEvent, LocalDateTime start) { + public static Instant getEnd(RelativeEvent event, Instant start, Instant end) { + return parseToObservationSchedulesForRelativeEvent(event, start, end) + .stream().map(Pair::getRight).max(Instant::compareTo).orElse(null); + } + + public static List> parseToObservationSchedulesForRelativeEvent( + RelativeEvent event, Instant start, Instant end) { //TODO implement - Event event = (Event) scheduleEvent; + return List.of(); + } + public static List> parseToObservationSchedulesForEvent(Event event) { List> observationSchedules = new ArrayList<>(); if(event.getDateStart() != null && event.getDateEnd() != null) { VEvent iCalEvent = parseToICalEvent(event); @@ -40,6 +45,14 @@ public static List> parseToObservationSchedules(ScheduleE return observationSchedules; } + public static List> parseToObservationSchedules(ScheduleEvent scheduleEvent, Instant start, Instant end) { + if(Event.class.isAssignableFrom(scheduleEvent.getClass())) { + return parseToObservationSchedulesForEvent((Event) scheduleEvent); + } else { + return parseToObservationSchedulesForRelativeEvent((RelativeEvent) scheduleEvent, start, end); + } + } + private static long getEventTime(Event event) { return Duration.between(event.getDateStart(), event.getDateEnd()).getSeconds(); } diff --git a/src/main/java/io/redlink/more/data/service/ExternalService.java b/src/main/java/io/redlink/more/data/service/ExternalService.java index 2540b5e..e2f5f3c 100644 --- a/src/main/java/io/redlink/more/data/service/ExternalService.java +++ b/src/main/java/io/redlink/more/data/service/ExternalService.java @@ -1,16 +1,17 @@ package io.redlink.more.data.service; +import io.redlink.more.data.configuration.CachingConfiguration; import io.redlink.more.data.exception.BadRequestException; import io.redlink.more.data.exception.NotFoundException; import io.redlink.more.data.model.ApiRoutingInfo; import io.redlink.more.data.model.scheduler.Event; -import io.redlink.more.data.model.scheduler.ScheduleEvent; +import io.redlink.more.data.model.scheduler.Interval; +import io.redlink.more.data.model.scheduler.RelativeEvent; import io.redlink.more.data.repository.StudyRepository; +import org.springframework.cache.annotation.Cacheable; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import java.time.Instant; -import java.util.List; import java.util.Optional; import java.util.OptionalInt; @@ -46,25 +47,16 @@ public ApiRoutingInfo validateRoutingInfo(ApiRoutingInfo routingInfo, Integer pa return routingInfo.withParticipantStudyGroup(participantStudyGroup); } - public void validateTimeFrame(Long studyId, Integer observationId, List timestamps) { - Optional schedule = repository.getObservationSchedule(studyId, observationId); - if(schedule.isEmpty()){ - throw NotFoundException.Observation(observationId); - } - - //TODO implement and cache because of inefficiency - if(!Event.class.isAssignableFrom(schedule.get().getClass())) { - throw new RuntimeException("Schedule type currently not supported"); - } - - Event event = (Event) schedule.get(); - - Instant startDate = event.getDateStart(); - Instant endDate = event.getDateEnd(); - - timestamps.forEach(timestamp -> { - if(timestamp.isBefore(startDate) || timestamp.isAfter(endDate)) - throw BadRequestException.TimeFrame(); - }); + @Cacheable(CachingConfiguration.OBSERVATION_ENDINGS) + public Interval getIntervalForObservation(Long studyId, Integer observationId, Integer participantId) { + return repository.getObservationSchedule(studyId, observationId) + .map(scheduleEvent -> { + if(Event.class.isAssignableFrom(scheduleEvent.getClass())) { + return Interval.from((Event) scheduleEvent); + } else { + return repository.getInterval(studyId, participantId, (RelativeEvent) scheduleEvent); + } + }) + .orElseThrow(BadRequestException::TimeFrame); } } diff --git a/src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java b/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java similarity index 92% rename from src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java rename to src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java index 6ec4d26..c46bf15 100644 --- a/src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java +++ b/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java @@ -19,7 +19,7 @@ import static org.junit.jupiter.api.Assertions.*; @ExtendWith(MockitoExtension.class) -public class ICalendarParserTest { +public class SchedulerUtilsTest { DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @@ -42,7 +42,7 @@ void testParseDailyEvent() { .setFreq("DAILY") .setInterval(1) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(eventCount, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(eventCount, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); @@ -54,7 +54,7 @@ void testParseDailyEvent() { .setInterval(1) .setUntil(LocalDateTime.parse("2022-11-25 14:00:00", formatter).toInstant(ZoneOffset.UTC))); - actualValues = ICalendarParser.parseToObservationSchedules(eventUntil, LocalDateTime.now()); + actualValues = SchedulerUtils.parseToObservationSchedules(eventUntil, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -77,7 +77,7 @@ void testParseDailyEventWith30MinDuration() { .setFreq("DAILY") .setInterval(1) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(eventCount, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(eventCount, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); @@ -89,7 +89,7 @@ void testParseDailyEventWith30MinDuration() { .setInterval(1) .setUntil(LocalDateTime.parse("2022-11-25 14:00:00", formatter).toInstant(ZoneOffset.UTC))); - actualValues = ICalendarParser.parseToObservationSchedules(eventUntil, LocalDateTime.now()); + actualValues = SchedulerUtils.parseToObservationSchedules(eventUntil, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @Test @@ -114,7 +114,7 @@ void testParseMonthlyEvent() { .setBySetPos(1) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -153,7 +153,7 @@ void testParseMonthlyEventByDays() { .setByDay(List.of(new String[]{"MO", "TU", "WE"})) .setBySetPos(1) .setCount(9)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -177,7 +177,7 @@ void testParseWeeklyEvent() { .setInterval(1) .setByDay(List.of(new String[]{"WE"})) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -202,7 +202,7 @@ void testParseYearlyEvent() { .setByMonthDay(5) .setByMonth(12) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -228,7 +228,7 @@ void testParseYearlyEventBySetPos() { .setByDay(List.of(new String[]{"MO"})) .setByMonth(12) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -262,7 +262,7 @@ void testParseYearlyEventBySetPosAndByDays() { .setByDay(List.of(new String[]{"MO", "TU"})) .setByMonth(12) .setCount(6)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -286,7 +286,7 @@ void testParseHourlyEvent() { .setFreq("HOURLY") .setInterval(2) .setUntil(LocalDateTime.parse("2022-12-05 20:00:00", formatter).toInstant(ZoneOffset.UTC))); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } From 8b76ae9ccaa9061438e2d328691b59ec8613d691 Mon Sep 17 00:00:00 2001 From: iaigner Date: Thu, 16 Nov 2023 16:06:51 +0100 Subject: [PATCH 03/15] TT-13: Adapt Github Pipeline for Gateway --- .../workflows/compile-test-deploy-push.yml | 71 +++++++++++++++++++ .github/workflows/compile-test.yml | 28 -------- 2 files changed, 71 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/compile-test-deploy-push.yml diff --git a/.github/workflows/compile-test-deploy-push.yml b/.github/workflows/compile-test-deploy-push.yml new file mode 100644 index 0000000..7e2fed9 --- /dev/null +++ b/.github/workflows/compile-test-deploy-push.yml @@ -0,0 +1,71 @@ +name: Test, Deploy and Push Image +on: + workflow_dispatch: + +jobs: + Compile-and-Test: + name: Compile and Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: 17 + - name: Compile and test project + run: ./mvnw -B -U + --no-transfer-progress + compile test + - name: Show 3rd-Party Licenses + run: | + cat ./target/generated-sources/license/THIRD-PARTY.txt + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v3 + with: + name: Test Results + path: "**/TEST-*.xml" + - name: Upload Licenses List + if: always() + uses: actions/upload-artifact@v3 + with: + name: Licenses List + path: "./target/generated-sources/license/THIRD-PARTY.txt" + + Build-and-Deploy: + name: "Build and Push Docker Image" + runs-on: ubuntu-latest + needs: + - Compile-and-Test + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: 17 + - name: Build JIB container and publish to GitHub Packages + run: ./mvnw -B -U + --no-transfer-progress + clean verify jib:build + -Drevision=${{github.run_number}} + -Dchangelist= + -Dsha1=.${GITHUB_SHA:0:7} + -Dquick + -Ddocker.namespace=${DOCKER_NAMESPACE,,} + -Djib.to.tags=latest + -Djib.to.auth.username=${{ github.actor }} + -Djib.to.auth.password=${{ secrets.GITHUB_TOKEN }} + env: + DOCKER_NAMESPACE: ghcr.io/${{ github.repository_owner }} + + event_file: + name: "Event File" + runs-on: ubuntu-latest + steps: + - name: Upload + uses: actions/upload-artifact@v3 + with: + name: Event File + path: ${{ github.event_path }} \ No newline at end of file diff --git a/.github/workflows/compile-test.yml b/.github/workflows/compile-test.yml index b547a58..5360e15 100644 --- a/.github/workflows/compile-test.yml +++ b/.github/workflows/compile-test.yml @@ -34,34 +34,6 @@ jobs: name: Licenses List path: "./target/generated-sources/license/THIRD-PARTY.txt" - Build-and-Deploy: - name: "Build and Push Docker Image" - runs-on: ubuntu-latest - if: github.ref_name == 'main' - needs: - - Compile-and-Test - steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'temurin' - java-version: 17 - - name: Build JIB container and publish to GitHub Packages - run: ./mvnw -B -U - --no-transfer-progress - clean verify jib:build - -Drevision=${{github.run_number}} - -Dchangelist= - -Dsha1=.${GITHUB_SHA:0:7} - -Dquick - -Ddocker.namespace=${DOCKER_NAMESPACE,,} - -Djib.to.tags=latest - -Djib.to.auth.username=${{ github.actor }} - -Djib.to.auth.password=${{ secrets.GITHUB_TOKEN }} - env: - DOCKER_NAMESPACE: ghcr.io/${{ github.repository_owner }} - event_file: name: "Event File" runs-on: ubuntu-latest From b2d3e64ee24bb7870e4cd8d8f09f4ba2544dc6ae Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Thu, 16 Nov 2023 19:38:43 +0100 Subject: [PATCH 04/15] MORE2-5 implement relative schedule calculation --- .../more/data/model/scheduler/Duration.java | 2 +- .../data/model/scheduler/RelativeDate.java | 23 ++++++ .../more/data/schedule/SchedulerUtils.java | 42 +++++++++- .../data/schedule/SchedulerUtilsTest.java | 78 ++++++++++++++++++- 4 files changed, 137 insertions(+), 8 deletions(-) diff --git a/src/main/java/io/redlink/more/data/model/scheduler/Duration.java b/src/main/java/io/redlink/more/data/model/scheduler/Duration.java index 561d2f7..4b5a9b6 100644 --- a/src/main/java/io/redlink/more/data/model/scheduler/Duration.java +++ b/src/main/java/io/redlink/more/data/model/scheduler/Duration.java @@ -44,7 +44,7 @@ public String toString() { } public TemporalUnit toTemporalUnit() { - return ChronoUnit.valueOf(value); + return ChronoUnit.valueOf(value+"S"); } @JsonCreator diff --git a/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java b/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java index 54811d2..9fab0ff 100644 --- a/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java +++ b/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java @@ -1,13 +1,36 @@ package io.redlink.more.data.model.scheduler; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + public class RelativeDate { + private static Pattern CLOCK = Pattern.compile("(\\d\\d):(\\d\\d):(\\d\\d)"); + private Duration offset; private String time; public RelativeDate() { } + public int getHours() { + return getTimeGroup(1); + } + + public int getMinutes() { + return getTimeGroup(2); + } + + public int getSeconds() { + return getTimeGroup(3); + } + + private int getTimeGroup(int i) { + Matcher m = CLOCK.matcher(time); + m.find(); + return Integer.parseInt(m.group(i)); + } + public Duration getOffset() { return offset; } diff --git a/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java index bbc8da2..f5bbb19 100644 --- a/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java +++ b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java @@ -9,8 +9,8 @@ import org.apache.commons.lang3.tuple.Pair; import java.sql.Date; +import java.time.*; import java.time.Duration; -import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @@ -25,10 +25,44 @@ public static Instant getEnd(RelativeEvent event, Instant start, Instant end) { } public static List> parseToObservationSchedulesForRelativeEvent( - RelativeEvent event, Instant start, Instant end) { - //TODO implement - return List.of(); + RelativeEvent event, Instant start, Instant maxEnd) { + + List> events = new ArrayList<>(); + + start = shiftStartIfNecessary(start); + + Pair currentEvt = Pair.of(toInstant(event.getDtstart(), start), toInstant(event.getDtend(), start)); + + if(event.getRrrule() != null) { + RelativeRecurrenceRule rrule = event.getRrrule(); + Instant maxEndOfRule = currentEvt.getRight().plus(rrule.getEndAfter().getValue(), rrule.getEndAfter().getUnit().toTemporalUnit()); + maxEnd = maxEnd.isBefore(maxEndOfRule) ? maxEnd : maxEndOfRule; + long durationInMs = currentEvt.getRight().toEpochMilli() - currentEvt.getLeft().toEpochMilli(); + + while(currentEvt.getRight().isBefore(maxEnd)) { + events.add(currentEvt); + Instant estart = currentEvt.getLeft().plus(rrule.getFrequency().getValue(), rrule.getFrequency().getUnit().toTemporalUnit()); + currentEvt = Pair.of(estart, estart.plusMillis(durationInMs)); + } + } else { + events.add(currentEvt); + } + + return events; + } + + private static Instant shiftStartIfNecessary(Instant start) { + // TODO + return start; } + + public static Instant toInstant(RelativeDate date, Instant start) { + return ZonedDateTime.ofInstant(start.plus(date.getOffset().getValue() - 1L, date.getOffset().getUnit().toTemporalUnit()), ZoneId.systemDefault()) + .withHour(date.getHours()) + .withMinute(date.getMinutes()) + .withSecond(date.getSeconds()).toInstant(); + } + public static List> parseToObservationSchedulesForEvent(Event event) { List> observationSchedules = new ArrayList<>(); if(event.getDateStart() != null && event.getDateEnd() != null) { diff --git a/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java b/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java index c46bf15..6999da4 100644 --- a/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java +++ b/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java @@ -1,8 +1,8 @@ package io.redlink.more.data.schedule; -import io.redlink.more.data.model.scheduler.Event; -import io.redlink.more.data.model.scheduler.RecurrenceRule; +import io.redlink.more.data.model.scheduler.*; import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -288,6 +288,78 @@ void testParseHourlyEvent() { .setUntil(LocalDateTime.parse("2022-12-05 20:00:00", formatter).toInstant(ZoneOffset.UTC))); List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), - Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } + Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); + } + + @Test + @DisplayName("Parsing relative event without recursion") + void testRelativeEvent() { + RelativeEvent event = new RelativeEvent() + .setDtstart( + new RelativeDate() + .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) + .setTime("10:00:00") + ).setDtend( + new RelativeDate() + .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) + .setTime("11:30:00") + ); + + Instant start = Instant.ofEpochSecond(1700118000); // Thursday, 30. November 2023 00:00:00 + Instant maxEnd = Instant.ofEpochSecond(1701302400); // Thursday, 16. November 2023 07:00:00 + + List> events = SchedulerUtils.parseToObservationSchedulesForRelativeEvent(event, start, maxEnd); + Assertions.assertEquals(1, events.size()); + } + + @Test + @DisplayName("Parsing relative event with recursion") + void testRelativeEventWithRecursion() { + RelativeEvent event = new RelativeEvent() + .setDtstart( + new RelativeDate() + .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) + .setTime("10:00:00") + ).setDtend( + new RelativeDate() + .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) + .setTime("11:30:00") + ).setRrrule( + new RelativeRecurrenceRule() + .setEndAfter(new Duration().setValue(10).setUnit(Duration.Unit.DAY)) + .setFrequency(new Duration().setValue(2).setUnit(Duration.Unit.DAY)) + ); + + Instant start = Instant.ofEpochSecond(1700118000); // Thursday, 16. November 2023 07:00:00 + Instant maxEnd = Instant.ofEpochSecond(1701302400); // Thursday, 30. November 2023 00:00:00 + + List> events = SchedulerUtils.parseToObservationSchedulesForRelativeEvent(event, start, maxEnd); + Assertions.assertEquals(5, events.size()); + } + + @Test + @DisplayName("Parsing relative event with recursion (long run)") + void testRelativeEventWithRecursionLongRun() { + RelativeEvent event = new RelativeEvent() + .setDtstart( + new RelativeDate() + .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) + .setTime("10:00:00") + ).setDtend( + new RelativeDate() + .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) + .setTime("11:30:00") + ).setRrrule( + new RelativeRecurrenceRule() + .setEndAfter(new Duration().setValue(100).setUnit(Duration.Unit.DAY)) + .setFrequency(new Duration().setValue(3).setUnit(Duration.Unit.DAY)) + ); + + Instant start = Instant.ofEpochSecond(1700118000); // Thursday, 16. November 2023 07:00:00 + Instant maxEnd = Instant.ofEpochSecond(1701302400); // Thursday, 30. November 2023 00:00:00 + + List> events = SchedulerUtils.parseToObservationSchedulesForRelativeEvent(event, start, maxEnd); + Assertions.assertEquals(5, events.size()); + } } From 81d0963d32fa6de5ca4dfaa1ec921aaa3d2b34b5 Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Wed, 15 Nov 2023 15:46:14 +0100 Subject: [PATCH 05/15] MORE2-5 set participants start datetime on registration --- .../transformer/StudyTransformer.java | 13 ++-- .../redlink/more/data/model/Observation.java | 4 +- .../more/data/model/SimpleParticipant.java | 5 +- .../io/redlink/more/data/model/Study.java | 2 - .../more/data/model/scheduler/Duration.java | 75 +++++++++++++++++++ .../data/model/{ => scheduler}/Event.java | 13 +++- .../model/{ => scheduler}/RecurrenceRule.java | 2 +- .../data/model/scheduler/RelativeDate.java | 28 +++++++ .../data/model/scheduler/RelativeEvent.java | 54 +++++++++++++ .../scheduler/RelativeRecurrenceRule.java | 29 +++++++ .../data/model/scheduler/ScheduleEvent.java | 18 +++++ .../redlink/more/data/repository/DbUtils.java | 6 +- .../more/data/repository/StudyRepository.java | 16 ++-- .../more/data/schedule/ICalendarParser.java | 16 ++-- .../more/data/service/ExternalService.java | 18 +++-- .../data/schedule/ICalendarParserTest.java | 26 +++---- 16 files changed, 280 insertions(+), 45 deletions(-) create mode 100644 src/main/java/io/redlink/more/data/model/scheduler/Duration.java rename src/main/java/io/redlink/more/data/model/{ => scheduler}/Event.java (74%) rename src/main/java/io/redlink/more/data/model/{ => scheduler}/RecurrenceRule.java (97%) create mode 100644 src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java create mode 100644 src/main/java/io/redlink/more/data/model/scheduler/RelativeEvent.java create mode 100644 src/main/java/io/redlink/more/data/model/scheduler/RelativeRecurrenceRule.java create mode 100644 src/main/java/io/redlink/more/data/model/scheduler/ScheduleEvent.java diff --git a/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java b/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java index c59ec70..fc6d9f8 100644 --- a/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java +++ b/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java @@ -12,6 +12,7 @@ import org.apache.commons.lang3.tuple.Pair; import java.time.Instant; +import java.time.LocalDateTime; import java.util.List; public final class StudyTransformer { @@ -30,7 +31,7 @@ public static StudyDTO toDTO(Study study) { .contact(toDTO(study.contact())) .start(study.startDate()) .end(study.endDate()) - .observations(toDTO(study.observations())) + .observations(toDTO(study.observations(), study.participant().start())) .version(BaseTransformers.toVersionTag(study.modified())) ; } @@ -53,11 +54,11 @@ public static ContactInfoDTO toDTO(Contact contact) { ; } - public static List toDTO(List observations) { - return observations.stream().map(StudyTransformer::toDTO).toList(); + public static List toDTO(List observations, LocalDateTime start) { + return observations.stream().map(o -> StudyTransformer.toDTO(o, start)).toList(); } - public static ObservationDTO toDTO(Observation observation) { + public static ObservationDTO toDTO(Observation observation, LocalDateTime start) { ObservationDTO dto = new ObservationDTO() .observationId(String.valueOf(observation.observationId())) .observationType(observation.type()) @@ -68,9 +69,9 @@ public static ObservationDTO toDTO(Observation observation) { .hidden(observation.hidden()) .noSchedule(observation.noSchedule()) ; - if(observation.observationSchedule() != null) { + if(observation.observationSchedule() != null && start != null) { dto.schedule(ICalendarParser - .parseToObservationSchedules(observation.observationSchedule()) + .parseToObservationSchedules(observation.observationSchedule(), start) .stream() .map(StudyTransformer::toObservationScheduleDTO) .toList()); diff --git a/src/main/java/io/redlink/more/data/model/Observation.java b/src/main/java/io/redlink/more/data/model/Observation.java index 0a7d039..6037555 100644 --- a/src/main/java/io/redlink/more/data/model/Observation.java +++ b/src/main/java/io/redlink/more/data/model/Observation.java @@ -1,5 +1,7 @@ package io.redlink.more.data.model; +import io.redlink.more.data.model.scheduler.ScheduleEvent; + import java.time.Instant; public record Observation( @@ -8,7 +10,7 @@ public record Observation( String type, String participantInfo, Object properties, - Event observationSchedule, + ScheduleEvent observationSchedule, Instant created, Instant modified, boolean hidden, diff --git a/src/main/java/io/redlink/more/data/model/SimpleParticipant.java b/src/main/java/io/redlink/more/data/model/SimpleParticipant.java index 60f68b4..fe1d9ed 100644 --- a/src/main/java/io/redlink/more/data/model/SimpleParticipant.java +++ b/src/main/java/io/redlink/more/data/model/SimpleParticipant.java @@ -1,7 +1,10 @@ package io.redlink.more.data.model; +import java.time.LocalDateTime; + public record SimpleParticipant( int id, - String alias + String alias, + LocalDateTime start ) { } diff --git a/src/main/java/io/redlink/more/data/model/Study.java b/src/main/java/io/redlink/more/data/model/Study.java index 9524c8b..f316018 100644 --- a/src/main/java/io/redlink/more/data/model/Study.java +++ b/src/main/java/io/redlink/more/data/model/Study.java @@ -1,7 +1,5 @@ package io.redlink.more.data.model; -import io.redlink.more.data.api.app.v1.model.StudyDTO; - import java.time.Instant; import java.time.LocalDate; import java.util.List; diff --git a/src/main/java/io/redlink/more/data/model/scheduler/Duration.java b/src/main/java/io/redlink/more/data/model/scheduler/Duration.java new file mode 100644 index 0000000..7fa6998 --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/scheduler/Duration.java @@ -0,0 +1,75 @@ +package io.redlink.more.data.model.scheduler; + +import com.fasterxml.jackson.annotation.JsonCreator; + +public class Duration { + + private Integer value; + + /** + * unit of time to offset + */ + public enum Unit { + MINUTE("MINUTE"), + + HOUR("HOUR"), + + DAY("DAY"); + + private String value; + + Unit(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } + + @JsonCreator + public static Unit fromValue(String value) { + for (Unit b : Unit.values()) { + if (b.value.equals(value)) { + return b; + } + } + throw new IllegalArgumentException("Unexpected value '" + value + "'"); + } + } + + private Unit unit; + + public Duration() { + } + + public Integer getValue() { + return value; + } + + public Duration setValue(Integer value) { + this.value = value; + return this; + } + + public Unit getUnit() { + return unit; + } + + public Duration setUnit(Unit unit) { + this.unit = unit; + return this; + } + + @Override + public String toString() { + return "Duration{" + + "offset=" + value + + ", unit=" + unit + + '}'; + } +} diff --git a/src/main/java/io/redlink/more/data/model/Event.java b/src/main/java/io/redlink/more/data/model/scheduler/Event.java similarity index 74% rename from src/main/java/io/redlink/more/data/model/Event.java rename to src/main/java/io/redlink/more/data/model/scheduler/Event.java index 14524f8..2d1be61 100644 --- a/src/main/java/io/redlink/more/data/model/Event.java +++ b/src/main/java/io/redlink/more/data/model/scheduler/Event.java @@ -1,12 +1,19 @@ -package io.redlink.more.data.model; +package io.redlink.more.data.model.scheduler; import java.time.Instant; -public class Event { +public class Event implements ScheduleEvent { + public static final String TYPE = "Event"; + private String type; private Instant dateStart; private Instant dateEnd; private RecurrenceRule recurrenceRule; + @Override + public String getType() { + return TYPE; + } + public Instant getDateStart() { return dateStart; } @@ -33,4 +40,6 @@ public Event setRRule(RecurrenceRule recurrenceRule) { this.recurrenceRule = recurrenceRule; return this; } + + } diff --git a/src/main/java/io/redlink/more/data/model/RecurrenceRule.java b/src/main/java/io/redlink/more/data/model/scheduler/RecurrenceRule.java similarity index 97% rename from src/main/java/io/redlink/more/data/model/RecurrenceRule.java rename to src/main/java/io/redlink/more/data/model/scheduler/RecurrenceRule.java index 80a4093..25122bd 100644 --- a/src/main/java/io/redlink/more/data/model/RecurrenceRule.java +++ b/src/main/java/io/redlink/more/data/model/scheduler/RecurrenceRule.java @@ -1,4 +1,4 @@ -package io.redlink.more.data.model; +package io.redlink.more.data.model.scheduler; import java.time.Instant; import java.util.List; diff --git a/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java b/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java new file mode 100644 index 0000000..54811d2 --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java @@ -0,0 +1,28 @@ +package io.redlink.more.data.model.scheduler; + +public class RelativeDate { + + private Duration offset; + private String time; + + public RelativeDate() { + } + + public Duration getOffset() { + return offset; + } + + public RelativeDate setOffset(Duration offset) { + this.offset = offset; + return this; + } + + public String getTime() { + return time; + } + + public RelativeDate setTime(String time) { + this.time = time; + return this; + } +} diff --git a/src/main/java/io/redlink/more/data/model/scheduler/RelativeEvent.java b/src/main/java/io/redlink/more/data/model/scheduler/RelativeEvent.java new file mode 100644 index 0000000..2d6302f --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/scheduler/RelativeEvent.java @@ -0,0 +1,54 @@ +package io.redlink.more.data.model.scheduler; + +public class RelativeEvent implements ScheduleEvent { + + public static final String TYPE = "RelativeEvent"; + + private String type; + + private RelativeDate dtstart; + + private RelativeDate dtend; + + private RelativeRecurrenceRule rrrule; + + public RelativeEvent() { + } + + @Override + public String getType() { + return TYPE; + } + + public RelativeEvent setType(String type) { + this.type = type; + return this; + } + + public RelativeDate getDtstart() { + return dtstart; + } + + public RelativeEvent setDtstart(RelativeDate dtstart) { + this.dtstart = dtstart; + return this; + } + + public RelativeDate getDtend() { + return dtend; + } + + public RelativeEvent setDtend(RelativeDate dtend) { + this.dtend = dtend; + return this; + } + + public RelativeRecurrenceRule getRrrule() { + return rrrule; + } + + public RelativeEvent setRrrule(RelativeRecurrenceRule rrrule) { + this.rrrule = rrrule; + return this; + } +} diff --git a/src/main/java/io/redlink/more/data/model/scheduler/RelativeRecurrenceRule.java b/src/main/java/io/redlink/more/data/model/scheduler/RelativeRecurrenceRule.java new file mode 100644 index 0000000..f98726c --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/scheduler/RelativeRecurrenceRule.java @@ -0,0 +1,29 @@ +package io.redlink.more.data.model.scheduler; + +public class RelativeRecurrenceRule { + + private Duration frequency; + + private Duration endAfter; + + public RelativeRecurrenceRule() { + } + + public Duration getFrequency() { + return frequency; + } + + public RelativeRecurrenceRule setFrequency(Duration frequency) { + this.frequency = frequency; + return this; + } + + public Duration getEndAfter() { + return endAfter; + } + + public RelativeRecurrenceRule setEndAfter(Duration endAfter) { + this.endAfter = endAfter; + return this; + } +} diff --git a/src/main/java/io/redlink/more/data/model/scheduler/ScheduleEvent.java b/src/main/java/io/redlink/more/data/model/scheduler/ScheduleEvent.java new file mode 100644 index 0000000..22a971c --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/scheduler/ScheduleEvent.java @@ -0,0 +1,18 @@ +package io.redlink.more.data.model.scheduler; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonIgnoreProperties( + value = "type", // ignore manually set type, it will be automatically generated by Jackson during serialization + allowSetters = true // allows the type to be set during deserialization +) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type", visible = true, defaultImpl = Event.class) +@JsonSubTypes({ + @JsonSubTypes.Type(value = Event.class, name = Event.TYPE), + @JsonSubTypes.Type(value = RelativeEvent.class, name = RelativeEvent.TYPE) +}) +public interface ScheduleEvent { + public String getType(); +} diff --git a/src/main/java/io/redlink/more/data/repository/DbUtils.java b/src/main/java/io/redlink/more/data/repository/DbUtils.java index 65bee7c..c8136fd 100644 --- a/src/main/java/io/redlink/more/data/repository/DbUtils.java +++ b/src/main/java/io/redlink/more/data/repository/DbUtils.java @@ -7,7 +7,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import io.redlink.more.data.model.Event; +import io.redlink.more.data.model.scheduler.ScheduleEvent; import java.sql.*; import java.time.Instant; @@ -50,11 +50,11 @@ public static OptionalInt readOptionalInt(ResultSet row, String columnLabel) thr } } - public static Event readEvent(ResultSet row, String columnLabel) throws SQLException { + public static ScheduleEvent readEvent(ResultSet row, String columnLabel) throws SQLException { var rawValue = row.getString(columnLabel); if(rawValue == null) return null; try { - return MAPPER.readValue(rawValue, Event.class); + return MAPPER.readValue(rawValue, ScheduleEvent.class); } catch (JsonProcessingException e) { throw new SQLDataException("Could not read Event from column '" + columnLabel + "'", e); } diff --git a/src/main/java/io/redlink/more/data/repository/StudyRepository.java b/src/main/java/io/redlink/more/data/repository/StudyRepository.java index 980f09a..9379b42 100644 --- a/src/main/java/io/redlink/more/data/repository/StudyRepository.java +++ b/src/main/java/io/redlink/more/data/repository/StudyRepository.java @@ -7,10 +7,14 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.OptionalInt; import java.util.function.Supplier; + +import io.redlink.more.data.model.scheduler.ScheduleEvent; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -72,7 +76,7 @@ public class StudyRepository { "ON CONFLICT (study_id, participant_id, observation_id) DO NOTHING"; private static final String SQL_SET_PARTICIPANT_STATUS = "UPDATE participants " + - "SET status = :newStatus::participant_status, modified = now() " + + "SET status = :newStatus::participant_status, start = :start, modified = now() " + "WHERE study_id = :study_id AND participant_id = :participant_id AND status = :oldStatus::participant_status"; private static final String GET_OBSERVATION_PROPERTIES_FOR_PARTICIPANT = @@ -124,7 +128,7 @@ public Optional getParticipantStudyGroupId(Long studyId, Integer pa } } - public Optional getObservationSchedule(Long studyId, Integer observationId) { + public Optional getObservationSchedule(Long studyId, Integer observationId) { try (var stream = jdbcTemplate.queryForStream( GET_OBSERVATION_SCHEDULE, getObservationScheduleRowMapper(), @@ -154,7 +158,8 @@ public Optional findParticipant(RoutingInfo routingInfo) { try (var stream = jdbcTemplate.queryForStream(SQL_FIND_PARTICIPANT_BY_STUDY_AND_ID, (rs, rowNum) -> new SimpleParticipant( rs.getInt("participant_id"), - rs.getString("alias") + rs.getString("alias"), + Optional.ofNullable(rs.getTimestamp("start")).map(Timestamp::toLocalDateTime).orElse(null) ) , routingInfo.studyId(), routingInfo.participantId())) { return stream.findFirst(); @@ -191,7 +196,7 @@ private static RowMapper getParticipantObservationPropertiesRowMapper() return (rs, rowNum) -> DbUtils.readObject(rs,"properties"); } - private static RowMapper getObservationScheduleRowMapper() { + private static RowMapper getObservationScheduleRowMapper() { return (rs, rowNum) -> DbUtils.readEvent(rs, "schedule"); } @@ -203,7 +208,7 @@ public Optional createCredentials(String registrationToken, ParticipantC var routingInfo = ri.get(); final String secret = passwordSupplier.get(); - storeConsent(routingInfo.studyId(), routingInfo.participantId(), consent); + //storeConsent(routingInfo.studyId(), routingInfo.participantId(), consent); final String apiId = namedTemplate.queryForObject(SQL_INSERT_CREDENTIALS, toParameterSource(routingInfo.studyId(), routingInfo.participantId()) @@ -221,6 +226,7 @@ public Optional createCredentials(String registrationToken, ParticipantC private void updateParticipantStatus(long studyId, int particpantId, String oldStatus, String newStatus) { namedTemplate.update(SQL_SET_PARTICIPANT_STATUS, toParameterSource(studyId, particpantId) + .addValue("start", "active".equals(newStatus) ? Timestamp.valueOf(LocalDateTime.now()) : null) .addValue("oldStatus", oldStatus) .addValue("newStatus", newStatus) ); diff --git a/src/main/java/io/redlink/more/data/schedule/ICalendarParser.java b/src/main/java/io/redlink/more/data/schedule/ICalendarParser.java index c94dbe1..97c952e 100644 --- a/src/main/java/io/redlink/more/data/schedule/ICalendarParser.java +++ b/src/main/java/io/redlink/more/data/schedule/ICalendarParser.java @@ -5,13 +5,15 @@ import biweekly.util.Frequency; import biweekly.util.Recurrence; import biweekly.util.com.google.ical.compat.javautil.DateIterator; -import io.redlink.more.data.model.Event; -import io.redlink.more.data.model.RecurrenceRule; +import io.redlink.more.data.model.scheduler.Event; +import io.redlink.more.data.model.scheduler.RecurrenceRule; +import io.redlink.more.data.model.scheduler.ScheduleEvent; import org.apache.commons.lang3.tuple.Pair; import java.sql.Date; import java.time.Duration; import java.time.Instant; +import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @@ -20,16 +22,18 @@ public class ICalendarParser { - public static List> parseToObservationSchedules(Event event) { + public static List> parseToObservationSchedules(ScheduleEvent scheduleEvent, LocalDateTime start) { + //TODO implement + Event event = (Event) scheduleEvent; List> observationSchedules = new ArrayList<>(); if(event.getDateStart() != null && event.getDateEnd() != null) { VEvent iCalEvent = parseToICalEvent(event); long eventDuration = getEventTime(event); DateIterator it = iCalEvent.getDateIterator(TimeZone.getDefault()); while (it.hasNext()) { - Instant start = it.next().toInstant(); - Instant end = start.plus(eventDuration, ChronoUnit.SECONDS); - observationSchedules.add(Pair.of(start, end)); + Instant ostart = it.next().toInstant(); + Instant oend = ostart.plus(eventDuration, ChronoUnit.SECONDS); + observationSchedules.add(Pair.of(ostart, oend)); } } // TODO edge cases if calculated days are not consecutive (e.g. first weekend -> first of month is a sunday) diff --git a/src/main/java/io/redlink/more/data/service/ExternalService.java b/src/main/java/io/redlink/more/data/service/ExternalService.java index dcf0a53..2540b5e 100644 --- a/src/main/java/io/redlink/more/data/service/ExternalService.java +++ b/src/main/java/io/redlink/more/data/service/ExternalService.java @@ -3,13 +3,13 @@ import io.redlink.more.data.exception.BadRequestException; import io.redlink.more.data.exception.NotFoundException; import io.redlink.more.data.model.ApiRoutingInfo; -import io.redlink.more.data.model.Event; +import io.redlink.more.data.model.scheduler.Event; +import io.redlink.more.data.model.scheduler.ScheduleEvent; import io.redlink.more.data.repository.StudyRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.time.Instant; -import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; import java.util.OptionalInt; @@ -47,12 +47,20 @@ public ApiRoutingInfo validateRoutingInfo(ApiRoutingInfo routingInfo, Integer pa } public void validateTimeFrame(Long studyId, Integer observationId, List timestamps) { - Optional schedule = repository.getObservationSchedule(studyId, observationId); + Optional schedule = repository.getObservationSchedule(studyId, observationId); if(schedule.isEmpty()){ throw NotFoundException.Observation(observationId); } - Instant startDate = schedule.get().getDateStart(); - Instant endDate = schedule.get().getDateEnd(); + + //TODO implement and cache because of inefficiency + if(!Event.class.isAssignableFrom(schedule.get().getClass())) { + throw new RuntimeException("Schedule type currently not supported"); + } + + Event event = (Event) schedule.get(); + + Instant startDate = event.getDateStart(); + Instant endDate = event.getDateEnd(); timestamps.forEach(timestamp -> { if(timestamp.isBefore(startDate) || timestamp.isAfter(endDate)) diff --git a/src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java b/src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java index 140d95e..6ec4d26 100644 --- a/src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java +++ b/src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java @@ -1,7 +1,7 @@ package io.redlink.more.data.schedule; -import io.redlink.more.data.model.Event; -import io.redlink.more.data.model.RecurrenceRule; +import io.redlink.more.data.model.scheduler.Event; +import io.redlink.more.data.model.scheduler.RecurrenceRule; import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -42,7 +42,7 @@ void testParseDailyEvent() { .setFreq("DAILY") .setInterval(1) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(eventCount); + List> actualValues = ICalendarParser.parseToObservationSchedules(eventCount, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); @@ -54,7 +54,7 @@ void testParseDailyEvent() { .setInterval(1) .setUntil(LocalDateTime.parse("2022-11-25 14:00:00", formatter).toInstant(ZoneOffset.UTC))); - actualValues = ICalendarParser.parseToObservationSchedules(eventUntil); + actualValues = ICalendarParser.parseToObservationSchedules(eventUntil, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -77,7 +77,7 @@ void testParseDailyEventWith30MinDuration() { .setFreq("DAILY") .setInterval(1) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(eventCount); + List> actualValues = ICalendarParser.parseToObservationSchedules(eventCount, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); @@ -89,7 +89,7 @@ void testParseDailyEventWith30MinDuration() { .setInterval(1) .setUntil(LocalDateTime.parse("2022-11-25 14:00:00", formatter).toInstant(ZoneOffset.UTC))); - actualValues = ICalendarParser.parseToObservationSchedules(eventUntil); + actualValues = ICalendarParser.parseToObservationSchedules(eventUntil, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @Test @@ -114,7 +114,7 @@ void testParseMonthlyEvent() { .setBySetPos(1) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -153,7 +153,7 @@ void testParseMonthlyEventByDays() { .setByDay(List.of(new String[]{"MO", "TU", "WE"})) .setBySetPos(1) .setCount(9)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -177,7 +177,7 @@ void testParseWeeklyEvent() { .setInterval(1) .setByDay(List.of(new String[]{"WE"})) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -202,7 +202,7 @@ void testParseYearlyEvent() { .setByMonthDay(5) .setByMonth(12) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -228,7 +228,7 @@ void testParseYearlyEventBySetPos() { .setByDay(List.of(new String[]{"MO"})) .setByMonth(12) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -262,7 +262,7 @@ void testParseYearlyEventBySetPosAndByDays() { .setByDay(List.of(new String[]{"MO", "TU"})) .setByMonth(12) .setCount(6)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -286,7 +286,7 @@ void testParseHourlyEvent() { .setFreq("HOURLY") .setInterval(2) .setUntil(LocalDateTime.parse("2022-12-05 20:00:00", formatter).toInstant(ZoneOffset.UTC))); - List> actualValues = ICalendarParser.parseToObservationSchedules(event); + List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } From 4fb2288db04f7038f86fcec3df13ed495ed9fb8a Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Thu, 16 Nov 2023 15:00:26 +0100 Subject: [PATCH 06/15] MORE2-5 enable relative events on all places --- pom.xml | 6 +++ .../configuration/CachingConfiguration.java | 31 +++++++++++ .../ExternalDataApiV1Controller.java | 14 +++-- .../transformer/StudyTransformer.java | 14 ++--- .../more/data/model/SimpleParticipant.java | 5 +- .../more/data/model/scheduler/Duration.java | 16 ++++++ .../more/data/model/scheduler/Interval.java | 25 +++++++++ .../redlink/more/data/repository/DbUtils.java | 11 ++++ .../more/data/repository/StudyRepository.java | 52 +++++++++++++++---- ...alendarParser.java => SchedulerUtils.java} | 27 +++++++--- .../more/data/service/ExternalService.java | 38 ++++++-------- ...arserTest.java => SchedulerUtilsTest.java} | 24 ++++----- 12 files changed, 199 insertions(+), 64 deletions(-) create mode 100644 src/main/java/io/redlink/more/data/configuration/CachingConfiguration.java create mode 100644 src/main/java/io/redlink/more/data/model/scheduler/Interval.java rename src/main/java/io/redlink/more/data/schedule/{ICalendarParser.java => SchedulerUtils.java} (81%) rename src/test/java/io/redlink/more/data/schedule/{ICalendarParserTest.java => SchedulerUtilsTest.java} (92%) diff --git a/pom.xml b/pom.xml index 7dbf408..8b76b4b 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,12 @@ spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-cache + + org.springframework.boot diff --git a/src/main/java/io/redlink/more/data/configuration/CachingConfiguration.java b/src/main/java/io/redlink/more/data/configuration/CachingConfiguration.java new file mode 100644 index 0000000..6168e7b --- /dev/null +++ b/src/main/java/io/redlink/more/data/configuration/CachingConfiguration.java @@ -0,0 +1,31 @@ +package io.redlink.more.data.configuration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; + +@Configuration +@EnableCaching +@EnableScheduling +public class CachingConfiguration { + + public static final String OBSERVATION_ENDINGS = "observationEndings"; + private static final Logger LOGGER = LoggerFactory.getLogger(CachingConfiguration.class); + @Bean + public CacheManager cacheManager() { + return new ConcurrentMapCacheManager(OBSERVATION_ENDINGS); + } + + @CacheEvict(allEntries = true, value = {OBSERVATION_ENDINGS}) + @Scheduled(fixedDelay = 60 * 60 * 1000 , initialDelay = 5000) + public void reportCacheEvict() { + LOGGER.info("Flush Cache"); + } +} diff --git a/src/main/java/io/redlink/more/data/controller/ExternalDataApiV1Controller.java b/src/main/java/io/redlink/more/data/controller/ExternalDataApiV1Controller.java index 7609795..9c3ae7b 100644 --- a/src/main/java/io/redlink/more/data/controller/ExternalDataApiV1Controller.java +++ b/src/main/java/io/redlink/more/data/controller/ExternalDataApiV1Controller.java @@ -4,8 +4,10 @@ import io.redlink.more.data.api.app.v1.model.ExternalDataDTO; import io.redlink.more.data.api.app.v1.webservices.ExternalDataApi; import io.redlink.more.data.controller.transformer.DataTransformer; +import io.redlink.more.data.exception.BadRequestException; import io.redlink.more.data.model.ApiRoutingInfo; import io.redlink.more.data.model.RoutingInfo; +import io.redlink.more.data.model.scheduler.Interval; import io.redlink.more.data.service.ElasticService; import io.redlink.more.data.service.ExternalService; import io.redlink.more.data.util.LoggingUtils; @@ -55,10 +57,14 @@ public ResponseEntity storeExternalBulk(String moreApiToken, EndpointDataB throw new AccessDeniedException("Invalid token"); } - externalService.validateTimeFrame(studyId, observationId, - endpointDataBulkDTO.getDataPoints().stream().map(datapoint -> - datapoint.getTimestamp().toInstant() - ).toList()); + Interval interval = externalService.getIntervalForObservation(studyId, observationId, participantId); + + endpointDataBulkDTO.getDataPoints().stream() + .map(datapoint -> datapoint.getTimestamp().toInstant()) + .map(timestamp -> timestamp.isBefore(interval.getStart()) || timestamp.isAfter(interval.getEnd())) + .filter(v -> v) + .findFirst() + .orElseThrow(BadRequestException::TimeFrame); final RoutingInfo routingInfo = new RoutingInfo( externalService.validateRoutingInfo(apiRoutingInfo.get(), participantId), diff --git a/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java b/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java index fc6d9f8..1aa5979 100644 --- a/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java +++ b/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java @@ -8,7 +8,7 @@ import io.redlink.more.data.model.Observation; import io.redlink.more.data.model.SimpleParticipant; import io.redlink.more.data.model.Study; -import io.redlink.more.data.schedule.ICalendarParser; +import io.redlink.more.data.schedule.SchedulerUtils; import org.apache.commons.lang3.tuple.Pair; import java.time.Instant; @@ -31,7 +31,7 @@ public static StudyDTO toDTO(Study study) { .contact(toDTO(study.contact())) .start(study.startDate()) .end(study.endDate()) - .observations(toDTO(study.observations(), study.participant().start())) + .observations(toDTO(study.observations(), study.participant().start(), study.participant().end())) .version(BaseTransformers.toVersionTag(study.modified())) ; } @@ -54,11 +54,11 @@ public static ContactInfoDTO toDTO(Contact contact) { ; } - public static List toDTO(List observations, LocalDateTime start) { - return observations.stream().map(o -> StudyTransformer.toDTO(o, start)).toList(); + public static List toDTO(List observations, Instant start, Instant end) { + return observations.stream().map(o -> StudyTransformer.toDTO(o, start, end)).toList(); } - public static ObservationDTO toDTO(Observation observation, LocalDateTime start) { + public static ObservationDTO toDTO(Observation observation, Instant start, Instant end) { ObservationDTO dto = new ObservationDTO() .observationId(String.valueOf(observation.observationId())) .observationType(observation.type()) @@ -70,8 +70,8 @@ public static ObservationDTO toDTO(Observation observation, LocalDateTime start) .noSchedule(observation.noSchedule()) ; if(observation.observationSchedule() != null && start != null) { - dto.schedule(ICalendarParser - .parseToObservationSchedules(observation.observationSchedule(), start) + dto.schedule(SchedulerUtils + .parseToObservationSchedules(observation.observationSchedule(), start, end) .stream() .map(StudyTransformer::toObservationScheduleDTO) .toList()); diff --git a/src/main/java/io/redlink/more/data/model/SimpleParticipant.java b/src/main/java/io/redlink/more/data/model/SimpleParticipant.java index fe1d9ed..a96eb04 100644 --- a/src/main/java/io/redlink/more/data/model/SimpleParticipant.java +++ b/src/main/java/io/redlink/more/data/model/SimpleParticipant.java @@ -1,10 +1,11 @@ package io.redlink.more.data.model; -import java.time.LocalDateTime; +import java.time.Instant; public record SimpleParticipant( int id, String alias, - LocalDateTime start + Instant start, + Instant end ) { } diff --git a/src/main/java/io/redlink/more/data/model/scheduler/Duration.java b/src/main/java/io/redlink/more/data/model/scheduler/Duration.java index 7fa6998..561d2f7 100644 --- a/src/main/java/io/redlink/more/data/model/scheduler/Duration.java +++ b/src/main/java/io/redlink/more/data/model/scheduler/Duration.java @@ -2,10 +2,22 @@ import com.fasterxml.jackson.annotation.JsonCreator; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; + public class Duration { private Integer value; + public Instant getEnd(Instant start) { + if(start == null) { + return null; + } + return start.plus(value, unit.toTemporalUnit()); + } + /** * unit of time to offset */ @@ -31,6 +43,10 @@ public String toString() { return String.valueOf(value); } + public TemporalUnit toTemporalUnit() { + return ChronoUnit.valueOf(value); + } + @JsonCreator public static Unit fromValue(String value) { for (Unit b : Unit.values()) { diff --git a/src/main/java/io/redlink/more/data/model/scheduler/Interval.java b/src/main/java/io/redlink/more/data/model/scheduler/Interval.java new file mode 100644 index 0000000..3a9cf7a --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/scheduler/Interval.java @@ -0,0 +1,25 @@ +package io.redlink.more.data.model.scheduler; + +import java.time.Instant; + +public class Interval { + private Instant start; + private Instant end; + + public Interval(Instant start, Instant end) { + this.start = start; + this.end = end; + } + + public static Interval from(Event event) { + return new Interval(event.getDateStart(), event.getDateEnd()); + } + + public Instant getStart() { + return start; + } + + public Instant getEnd() { + return end; + } +} diff --git a/src/main/java/io/redlink/more/data/repository/DbUtils.java b/src/main/java/io/redlink/more/data/repository/DbUtils.java index c8136fd..e5afc24 100644 --- a/src/main/java/io/redlink/more/data/repository/DbUtils.java +++ b/src/main/java/io/redlink/more/data/repository/DbUtils.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.redlink.more.data.model.scheduler.Duration; import io.redlink.more.data.model.scheduler.ScheduleEvent; import java.sql.*; @@ -60,6 +61,16 @@ public static ScheduleEvent readEvent(ResultSet row, String columnLabel) throws } } + public static Duration readDuration(ResultSet row, String columnLabel) throws SQLException { + var rawValue = row.getString(columnLabel); + if(rawValue == null) return null; + try { + return MAPPER.readValue(rawValue, Duration.class); + } catch (JsonProcessingException e) { + throw new SQLDataException("Could not read Duration from column '" + columnLabel + "'", e); + } + } + public static Object readObject(ResultSet row, String columnLabel) throws SQLException { var rawValue = row.getString(columnLabel); if(rawValue == null) return null; diff --git a/src/main/java/io/redlink/more/data/repository/StudyRepository.java b/src/main/java/io/redlink/more/data/repository/StudyRepository.java index 9379b42..da65850 100644 --- a/src/main/java/io/redlink/more/data/repository/StudyRepository.java +++ b/src/main/java/io/redlink/more/data/repository/StudyRepository.java @@ -8,13 +8,18 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; +import java.time.Instant; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.OptionalInt; import java.util.function.Supplier; +import io.redlink.more.data.model.scheduler.Duration; +import io.redlink.more.data.model.scheduler.Interval; +import io.redlink.more.data.model.scheduler.RelativeEvent; import io.redlink.more.data.model.scheduler.ScheduleEvent; +import io.redlink.more.data.schedule.SchedulerUtils; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -33,9 +38,6 @@ public class StudyRepository { private static final String SQL_FIND_STUDY_BY_ID = "SELECT * FROM studies WHERE study_id = ?"; - private static final String SQL_FIND_PARTICIPANT_BY_STUDY_AND_ID = - "SELECT * FROM participants WHERE study_id = ? AND participant_id = ?"; - private static final String SQL_LIST_OBSERVATIONS_BY_STUDY = "SELECT * FROM observations WHERE study_id = ? AND ( study_group_id IS NULL OR study_group_id = ? )"; @@ -93,6 +95,12 @@ public class StudyRepository { private static final String GET_OBSERVATION_SCHEDULE = "SELECT schedule FROM observations WHERE study_id = ? AND observation_id = ?"; + private static final String GET_PARTICIPANT_INFO_AND_START_DURATION_END_FOR_STUDY_AND_PARTICIPANT = + "SELECT start, participant_id, alias, COALESCE(sg.duration, s.duration) AS duration, s.planned_end_date FROM participants p " + + "LEFT OUTER JOIN study_groups sg on p.study_id = sg.study_id and p.study_group_id = sg.study_group_id " + + "JOIN studies s on p.study_id = s.study_id " + + "WHERE p.study_id = ? AND participant_id = ?"; + private final JdbcTemplate jdbcTemplate; private final NamedParameterJdbcTemplate namedTemplate; @@ -155,12 +163,20 @@ public Optional findStudy(RoutingInfo routingInfo) { } public Optional findParticipant(RoutingInfo routingInfo) { - try (var stream = jdbcTemplate.queryForStream(SQL_FIND_PARTICIPANT_BY_STUDY_AND_ID, - (rs, rowNum) -> new SimpleParticipant( - rs.getInt("participant_id"), - rs.getString("alias"), - Optional.ofNullable(rs.getTimestamp("start")).map(Timestamp::toLocalDateTime).orElse(null) - ) + try (var stream = jdbcTemplate.queryForStream(GET_PARTICIPANT_INFO_AND_START_DURATION_END_FOR_STUDY_AND_PARTICIPANT, + (rs, rowNum) -> { + Instant start = Optional.ofNullable(rs.getTimestamp("start")) + .map(Timestamp::toInstant).orElse(null); + Instant end = Optional.ofNullable(DbUtils.readDuration(rs, "duration")) + .map(d -> d.getEnd(start)) + .orElse(Instant.ofEpochMilli(rs.getDate("endDate").getTime())); + return new SimpleParticipant( + rs.getInt("participant_id"), + rs.getString("alias"), + start, + end + ); + } , routingInfo.studyId(), routingInfo.participantId())) { return stream.findFirst(); } @@ -346,4 +362,22 @@ private static MapSqlParameterSource toParameterSource(long studyId, int partici .addValue("observation_id", consent.observationId()) ; } + + public Interval getInterval(Long studyId, Integer participantId, RelativeEvent event) { + try(var stream = jdbcTemplate.queryForStream( + GET_PARTICIPANT_INFO_AND_START_DURATION_END_FOR_STUDY_AND_PARTICIPANT, + ((rs, rowNum) -> { + Instant start = rs.getTimestamp("start").toInstant(); + // TODO correct sql.Date to Instant with Time 0 ?! + Instant end = Optional.ofNullable(DbUtils.readDuration(rs, "duration")) + .map(d -> d.getEnd(start)) + .orElse(Instant.ofEpochMilli(rs.getDate("endDate").getTime())); + return new Interval(start, SchedulerUtils.getEnd(event, start, end)); + + }), + studyId, participantId + )) { + return stream.findFirst().orElse(null); + } + } } diff --git a/src/main/java/io/redlink/more/data/schedule/ICalendarParser.java b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java similarity index 81% rename from src/main/java/io/redlink/more/data/schedule/ICalendarParser.java rename to src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java index 97c952e..bbc8da2 100644 --- a/src/main/java/io/redlink/more/data/schedule/ICalendarParser.java +++ b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java @@ -5,26 +5,31 @@ import biweekly.util.Frequency; import biweekly.util.Recurrence; import biweekly.util.com.google.ical.compat.javautil.DateIterator; -import io.redlink.more.data.model.scheduler.Event; -import io.redlink.more.data.model.scheduler.RecurrenceRule; -import io.redlink.more.data.model.scheduler.ScheduleEvent; +import io.redlink.more.data.model.scheduler.*; import org.apache.commons.lang3.tuple.Pair; import java.sql.Date; import java.time.Duration; import java.time.Instant; -import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.TimeZone; -public class ICalendarParser { +public class SchedulerUtils { - public static List> parseToObservationSchedules(ScheduleEvent scheduleEvent, LocalDateTime start) { + public static Instant getEnd(RelativeEvent event, Instant start, Instant end) { + return parseToObservationSchedulesForRelativeEvent(event, start, end) + .stream().map(Pair::getRight).max(Instant::compareTo).orElse(null); + } + + public static List> parseToObservationSchedulesForRelativeEvent( + RelativeEvent event, Instant start, Instant end) { //TODO implement - Event event = (Event) scheduleEvent; + return List.of(); + } + public static List> parseToObservationSchedulesForEvent(Event event) { List> observationSchedules = new ArrayList<>(); if(event.getDateStart() != null && event.getDateEnd() != null) { VEvent iCalEvent = parseToICalEvent(event); @@ -40,6 +45,14 @@ public static List> parseToObservationSchedules(ScheduleE return observationSchedules; } + public static List> parseToObservationSchedules(ScheduleEvent scheduleEvent, Instant start, Instant end) { + if(Event.class.isAssignableFrom(scheduleEvent.getClass())) { + return parseToObservationSchedulesForEvent((Event) scheduleEvent); + } else { + return parseToObservationSchedulesForRelativeEvent((RelativeEvent) scheduleEvent, start, end); + } + } + private static long getEventTime(Event event) { return Duration.between(event.getDateStart(), event.getDateEnd()).getSeconds(); } diff --git a/src/main/java/io/redlink/more/data/service/ExternalService.java b/src/main/java/io/redlink/more/data/service/ExternalService.java index 2540b5e..e2f5f3c 100644 --- a/src/main/java/io/redlink/more/data/service/ExternalService.java +++ b/src/main/java/io/redlink/more/data/service/ExternalService.java @@ -1,16 +1,17 @@ package io.redlink.more.data.service; +import io.redlink.more.data.configuration.CachingConfiguration; import io.redlink.more.data.exception.BadRequestException; import io.redlink.more.data.exception.NotFoundException; import io.redlink.more.data.model.ApiRoutingInfo; import io.redlink.more.data.model.scheduler.Event; -import io.redlink.more.data.model.scheduler.ScheduleEvent; +import io.redlink.more.data.model.scheduler.Interval; +import io.redlink.more.data.model.scheduler.RelativeEvent; import io.redlink.more.data.repository.StudyRepository; +import org.springframework.cache.annotation.Cacheable; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import java.time.Instant; -import java.util.List; import java.util.Optional; import java.util.OptionalInt; @@ -46,25 +47,16 @@ public ApiRoutingInfo validateRoutingInfo(ApiRoutingInfo routingInfo, Integer pa return routingInfo.withParticipantStudyGroup(participantStudyGroup); } - public void validateTimeFrame(Long studyId, Integer observationId, List timestamps) { - Optional schedule = repository.getObservationSchedule(studyId, observationId); - if(schedule.isEmpty()){ - throw NotFoundException.Observation(observationId); - } - - //TODO implement and cache because of inefficiency - if(!Event.class.isAssignableFrom(schedule.get().getClass())) { - throw new RuntimeException("Schedule type currently not supported"); - } - - Event event = (Event) schedule.get(); - - Instant startDate = event.getDateStart(); - Instant endDate = event.getDateEnd(); - - timestamps.forEach(timestamp -> { - if(timestamp.isBefore(startDate) || timestamp.isAfter(endDate)) - throw BadRequestException.TimeFrame(); - }); + @Cacheable(CachingConfiguration.OBSERVATION_ENDINGS) + public Interval getIntervalForObservation(Long studyId, Integer observationId, Integer participantId) { + return repository.getObservationSchedule(studyId, observationId) + .map(scheduleEvent -> { + if(Event.class.isAssignableFrom(scheduleEvent.getClass())) { + return Interval.from((Event) scheduleEvent); + } else { + return repository.getInterval(studyId, participantId, (RelativeEvent) scheduleEvent); + } + }) + .orElseThrow(BadRequestException::TimeFrame); } } diff --git a/src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java b/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java similarity index 92% rename from src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java rename to src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java index 6ec4d26..c46bf15 100644 --- a/src/test/java/io/redlink/more/data/schedule/ICalendarParserTest.java +++ b/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java @@ -19,7 +19,7 @@ import static org.junit.jupiter.api.Assertions.*; @ExtendWith(MockitoExtension.class) -public class ICalendarParserTest { +public class SchedulerUtilsTest { DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @@ -42,7 +42,7 @@ void testParseDailyEvent() { .setFreq("DAILY") .setInterval(1) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(eventCount, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(eventCount, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); @@ -54,7 +54,7 @@ void testParseDailyEvent() { .setInterval(1) .setUntil(LocalDateTime.parse("2022-11-25 14:00:00", formatter).toInstant(ZoneOffset.UTC))); - actualValues = ICalendarParser.parseToObservationSchedules(eventUntil, LocalDateTime.now()); + actualValues = SchedulerUtils.parseToObservationSchedules(eventUntil, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -77,7 +77,7 @@ void testParseDailyEventWith30MinDuration() { .setFreq("DAILY") .setInterval(1) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(eventCount, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(eventCount, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); @@ -89,7 +89,7 @@ void testParseDailyEventWith30MinDuration() { .setInterval(1) .setUntil(LocalDateTime.parse("2022-11-25 14:00:00", formatter).toInstant(ZoneOffset.UTC))); - actualValues = ICalendarParser.parseToObservationSchedules(eventUntil, LocalDateTime.now()); + actualValues = SchedulerUtils.parseToObservationSchedules(eventUntil, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @Test @@ -114,7 +114,7 @@ void testParseMonthlyEvent() { .setBySetPos(1) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -153,7 +153,7 @@ void testParseMonthlyEventByDays() { .setByDay(List.of(new String[]{"MO", "TU", "WE"})) .setBySetPos(1) .setCount(9)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -177,7 +177,7 @@ void testParseWeeklyEvent() { .setInterval(1) .setByDay(List.of(new String[]{"WE"})) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -202,7 +202,7 @@ void testParseYearlyEvent() { .setByMonthDay(5) .setByMonth(12) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -228,7 +228,7 @@ void testParseYearlyEventBySetPos() { .setByDay(List.of(new String[]{"MO"})) .setByMonth(12) .setCount(3)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -262,7 +262,7 @@ void testParseYearlyEventBySetPosAndByDays() { .setByDay(List.of(new String[]{"MO", "TU"})) .setByMonth(12) .setCount(6)); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -286,7 +286,7 @@ void testParseHourlyEvent() { .setFreq("HOURLY") .setInterval(2) .setUntil(LocalDateTime.parse("2022-12-05 20:00:00", formatter).toInstant(ZoneOffset.UTC))); - List> actualValues = ICalendarParser.parseToObservationSchedules(event, LocalDateTime.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } From c44b6c0650f0f6068e734c99d244eadc243eff01 Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Thu, 16 Nov 2023 19:38:43 +0100 Subject: [PATCH 07/15] MORE2-5 implement relative schedule calculation --- .../more/data/model/scheduler/Duration.java | 2 +- .../data/model/scheduler/RelativeDate.java | 23 ++++++ .../more/data/schedule/SchedulerUtils.java | 42 +++++++++- .../data/schedule/SchedulerUtilsTest.java | 78 ++++++++++++++++++- 4 files changed, 137 insertions(+), 8 deletions(-) diff --git a/src/main/java/io/redlink/more/data/model/scheduler/Duration.java b/src/main/java/io/redlink/more/data/model/scheduler/Duration.java index 561d2f7..4b5a9b6 100644 --- a/src/main/java/io/redlink/more/data/model/scheduler/Duration.java +++ b/src/main/java/io/redlink/more/data/model/scheduler/Duration.java @@ -44,7 +44,7 @@ public String toString() { } public TemporalUnit toTemporalUnit() { - return ChronoUnit.valueOf(value); + return ChronoUnit.valueOf(value+"S"); } @JsonCreator diff --git a/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java b/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java index 54811d2..9fab0ff 100644 --- a/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java +++ b/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java @@ -1,13 +1,36 @@ package io.redlink.more.data.model.scheduler; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + public class RelativeDate { + private static Pattern CLOCK = Pattern.compile("(\\d\\d):(\\d\\d):(\\d\\d)"); + private Duration offset; private String time; public RelativeDate() { } + public int getHours() { + return getTimeGroup(1); + } + + public int getMinutes() { + return getTimeGroup(2); + } + + public int getSeconds() { + return getTimeGroup(3); + } + + private int getTimeGroup(int i) { + Matcher m = CLOCK.matcher(time); + m.find(); + return Integer.parseInt(m.group(i)); + } + public Duration getOffset() { return offset; } diff --git a/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java index bbc8da2..f5bbb19 100644 --- a/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java +++ b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java @@ -9,8 +9,8 @@ import org.apache.commons.lang3.tuple.Pair; import java.sql.Date; +import java.time.*; import java.time.Duration; -import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @@ -25,10 +25,44 @@ public static Instant getEnd(RelativeEvent event, Instant start, Instant end) { } public static List> parseToObservationSchedulesForRelativeEvent( - RelativeEvent event, Instant start, Instant end) { - //TODO implement - return List.of(); + RelativeEvent event, Instant start, Instant maxEnd) { + + List> events = new ArrayList<>(); + + start = shiftStartIfNecessary(start); + + Pair currentEvt = Pair.of(toInstant(event.getDtstart(), start), toInstant(event.getDtend(), start)); + + if(event.getRrrule() != null) { + RelativeRecurrenceRule rrule = event.getRrrule(); + Instant maxEndOfRule = currentEvt.getRight().plus(rrule.getEndAfter().getValue(), rrule.getEndAfter().getUnit().toTemporalUnit()); + maxEnd = maxEnd.isBefore(maxEndOfRule) ? maxEnd : maxEndOfRule; + long durationInMs = currentEvt.getRight().toEpochMilli() - currentEvt.getLeft().toEpochMilli(); + + while(currentEvt.getRight().isBefore(maxEnd)) { + events.add(currentEvt); + Instant estart = currentEvt.getLeft().plus(rrule.getFrequency().getValue(), rrule.getFrequency().getUnit().toTemporalUnit()); + currentEvt = Pair.of(estart, estart.plusMillis(durationInMs)); + } + } else { + events.add(currentEvt); + } + + return events; + } + + private static Instant shiftStartIfNecessary(Instant start) { + // TODO + return start; } + + public static Instant toInstant(RelativeDate date, Instant start) { + return ZonedDateTime.ofInstant(start.plus(date.getOffset().getValue() - 1L, date.getOffset().getUnit().toTemporalUnit()), ZoneId.systemDefault()) + .withHour(date.getHours()) + .withMinute(date.getMinutes()) + .withSecond(date.getSeconds()).toInstant(); + } + public static List> parseToObservationSchedulesForEvent(Event event) { List> observationSchedules = new ArrayList<>(); if(event.getDateStart() != null && event.getDateEnd() != null) { diff --git a/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java b/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java index c46bf15..6999da4 100644 --- a/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java +++ b/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java @@ -1,8 +1,8 @@ package io.redlink.more.data.schedule; -import io.redlink.more.data.model.scheduler.Event; -import io.redlink.more.data.model.scheduler.RecurrenceRule; +import io.redlink.more.data.model.scheduler.*; import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -288,6 +288,78 @@ void testParseHourlyEvent() { .setUntil(LocalDateTime.parse("2022-12-05 20:00:00", formatter).toInstant(ZoneOffset.UTC))); List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), - Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } + Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); + } + + @Test + @DisplayName("Parsing relative event without recursion") + void testRelativeEvent() { + RelativeEvent event = new RelativeEvent() + .setDtstart( + new RelativeDate() + .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) + .setTime("10:00:00") + ).setDtend( + new RelativeDate() + .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) + .setTime("11:30:00") + ); + + Instant start = Instant.ofEpochSecond(1700118000); // Thursday, 30. November 2023 00:00:00 + Instant maxEnd = Instant.ofEpochSecond(1701302400); // Thursday, 16. November 2023 07:00:00 + + List> events = SchedulerUtils.parseToObservationSchedulesForRelativeEvent(event, start, maxEnd); + Assertions.assertEquals(1, events.size()); + } + + @Test + @DisplayName("Parsing relative event with recursion") + void testRelativeEventWithRecursion() { + RelativeEvent event = new RelativeEvent() + .setDtstart( + new RelativeDate() + .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) + .setTime("10:00:00") + ).setDtend( + new RelativeDate() + .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) + .setTime("11:30:00") + ).setRrrule( + new RelativeRecurrenceRule() + .setEndAfter(new Duration().setValue(10).setUnit(Duration.Unit.DAY)) + .setFrequency(new Duration().setValue(2).setUnit(Duration.Unit.DAY)) + ); + + Instant start = Instant.ofEpochSecond(1700118000); // Thursday, 16. November 2023 07:00:00 + Instant maxEnd = Instant.ofEpochSecond(1701302400); // Thursday, 30. November 2023 00:00:00 + + List> events = SchedulerUtils.parseToObservationSchedulesForRelativeEvent(event, start, maxEnd); + Assertions.assertEquals(5, events.size()); + } + + @Test + @DisplayName("Parsing relative event with recursion (long run)") + void testRelativeEventWithRecursionLongRun() { + RelativeEvent event = new RelativeEvent() + .setDtstart( + new RelativeDate() + .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) + .setTime("10:00:00") + ).setDtend( + new RelativeDate() + .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) + .setTime("11:30:00") + ).setRrrule( + new RelativeRecurrenceRule() + .setEndAfter(new Duration().setValue(100).setUnit(Duration.Unit.DAY)) + .setFrequency(new Duration().setValue(3).setUnit(Duration.Unit.DAY)) + ); + + Instant start = Instant.ofEpochSecond(1700118000); // Thursday, 16. November 2023 07:00:00 + Instant maxEnd = Instant.ofEpochSecond(1701302400); // Thursday, 30. November 2023 00:00:00 + + List> events = SchedulerUtils.parseToObservationSchedulesForRelativeEvent(event, start, maxEnd); + Assertions.assertEquals(5, events.size()); + } } From 836fd0af70bd8c27191db2ef6ddb37c3ee01275b Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Fri, 17 Nov 2023 09:06:10 +0100 Subject: [PATCH 08/15] MORE2-5 enable ical export --- .../data/configuration/SecurityConfig.java | 4 +- .../controller/CalendarApiV1Controller.java | 28 ++++++++ .../redlink/more/data/model/Observation.java | 3 +- .../io/redlink/more/data/model/Study.java | 1 + .../more/data/model/StudyDurationInfo.java | 71 +++++++++++++++++++ .../data/model/scheduler/RelativeDate.java | 6 +- .../more/data/repository/StudyRepository.java | 44 ++++++++++-- .../more/data/schedule/SchedulerUtils.java | 5 +- .../more/data/service/CalendarService.java | 63 ++++++++++++++++ src/main/resources/openapi/ExternalAPI.yaml | 39 ++++++++++ .../data/schedule/SchedulerUtilsTest.java | 12 ++-- 11 files changed, 253 insertions(+), 23 deletions(-) create mode 100644 src/main/java/io/redlink/more/data/controller/CalendarApiV1Controller.java create mode 100644 src/main/java/io/redlink/more/data/model/StudyDurationInfo.java create mode 100644 src/main/java/io/redlink/more/data/service/CalendarService.java diff --git a/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java b/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java index b675c2c..7140a02 100644 --- a/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java +++ b/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java @@ -50,6 +50,8 @@ public SecurityFilterChain filterChain(HttpSecurity http, //External Data Gateway req.requestMatchers("/api/v1/external/bulk") .permitAll(); + req.requestMatchers("/api/v1/calendar/studies/*/calendar.ics") + .permitAll(); // all other apis require credentials req.requestMatchers("/api/v1/**") .authenticated(); @@ -97,4 +99,4 @@ protected RequestRejectedHandler requestRejectedHandler() { return new HttpStatusRequestRejectedHandler(HttpStatus.I_AM_A_TEAPOT.value()); } -} \ No newline at end of file +} diff --git a/src/main/java/io/redlink/more/data/controller/CalendarApiV1Controller.java b/src/main/java/io/redlink/more/data/controller/CalendarApiV1Controller.java new file mode 100644 index 0000000..9f363d7 --- /dev/null +++ b/src/main/java/io/redlink/more/data/controller/CalendarApiV1Controller.java @@ -0,0 +1,28 @@ +package io.redlink.more.data.controller; + +import io.redlink.more.data.api.app.v1.webservices.CalendarApi; +import io.redlink.more.data.service.CalendarService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Controller +@RestController +@RequestMapping(value = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE) +public class CalendarApiV1Controller implements CalendarApi { + + private final CalendarService calendarService; + + public CalendarApiV1Controller(CalendarService calendarService) { + this.calendarService = calendarService; + } + + @Override + public ResponseEntity getStudyCalendar(Long studyId) { + return this.calendarService.getICalendarString(studyId) + .map(ResponseEntity::ok) + .orElseThrow(RuntimeException::new); + } +} diff --git a/src/main/java/io/redlink/more/data/model/Observation.java b/src/main/java/io/redlink/more/data/model/Observation.java index 6037555..6cb73b4 100644 --- a/src/main/java/io/redlink/more/data/model/Observation.java +++ b/src/main/java/io/redlink/more/data/model/Observation.java @@ -6,6 +6,7 @@ public record Observation( int observationId, + Integer groupId, String title, String type, String participantInfo, @@ -18,7 +19,7 @@ public record Observation( ) { public Observation withProperties(Object properties) { return new Observation( - observationId, title, type, participantInfo, properties, observationSchedule, created, modified, hidden, noSchedule + observationId, groupId, title, type, participantInfo, properties, observationSchedule, created, modified, hidden, noSchedule ); } } diff --git a/src/main/java/io/redlink/more/data/model/Study.java b/src/main/java/io/redlink/more/data/model/Study.java index f316018..7a78bd7 100644 --- a/src/main/java/io/redlink/more/data/model/Study.java +++ b/src/main/java/io/redlink/more/data/model/Study.java @@ -14,6 +14,7 @@ public record Study( String consentInfo, Contact contact, LocalDate startDate, + LocalDate plannedStartDate, LocalDate endDate, List observations, Instant created, diff --git a/src/main/java/io/redlink/more/data/model/StudyDurationInfo.java b/src/main/java/io/redlink/more/data/model/StudyDurationInfo.java new file mode 100644 index 0000000..3304192 --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/StudyDurationInfo.java @@ -0,0 +1,71 @@ +package io.redlink.more.data.model; + +import io.redlink.more.data.model.scheduler.Duration; +import org.apache.commons.lang3.tuple.Pair; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +public class StudyDurationInfo{ + private LocalDate endDate; + private LocalDate startDate; + private Duration duration; + private final List> groupDurations = new ArrayList<>(); + + public LocalDate getEndDate() { + return endDate; + } + + public StudyDurationInfo setEndDate(LocalDate endDate) { + this.endDate = endDate; + return this; + } + + public LocalDate getStartDate() { + return startDate; + } + + public StudyDurationInfo setStartDate(LocalDate startDate) { + this.startDate = startDate; + return this; + } + + public Duration getDuration() { + return duration; + } + + public StudyDurationInfo setDuration(Duration duration) { + this.duration = duration; + return this; + } + + public List> getGroupDurations() { + return groupDurations; + } + + public StudyDurationInfo addGroupDuration(Pair gd) { + this.groupDurations.add(gd); + return this; + } + + public Duration getDurationFor(Integer group) { + if(group == null) { + return getDurationFallback(); + } else { + return this.groupDurations.stream() + .filter(gd -> gd.getLeft().equals(group)) + .findFirst() + .map(Pair::getRight) + .orElse(getDurationFallback()); + } + } + + private Duration getDurationFallback() { + if(this.duration != null) { + return duration; + } else { + return new Duration().setUnit(Duration.Unit.DAY).setValue((int) startDate.until(endDate, ChronoUnit.DAYS)); + } + } +} diff --git a/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java b/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java index 9fab0ff..830ba69 100644 --- a/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java +++ b/src/main/java/io/redlink/more/data/model/scheduler/RelativeDate.java @@ -5,7 +5,7 @@ public class RelativeDate { - private static Pattern CLOCK = Pattern.compile("(\\d\\d):(\\d\\d):(\\d\\d)"); + private static Pattern CLOCK = Pattern.compile("(\\d\\d):(\\d\\d)"); private Duration offset; private String time; @@ -21,10 +21,6 @@ public int getMinutes() { return getTimeGroup(2); } - public int getSeconds() { - return getTimeGroup(3); - } - private int getTimeGroup(int i) { Matcher m = CLOCK.matcher(time); m.find(); diff --git a/src/main/java/io/redlink/more/data/repository/StudyRepository.java b/src/main/java/io/redlink/more/data/repository/StudyRepository.java index da65850..a679dea 100644 --- a/src/main/java/io/redlink/more/data/repository/StudyRepository.java +++ b/src/main/java/io/redlink/more/data/repository/StudyRepository.java @@ -2,7 +2,7 @@ * Copyright (c) 2022 Redlink GmbH. */ package io.redlink.more.data.repository; - +import org.apache.commons.lang3.tuple.Pair; import io.redlink.more.data.model.*; import java.sql.ResultSet; @@ -15,7 +15,6 @@ import java.util.OptionalInt; import java.util.function.Supplier; -import io.redlink.more.data.model.scheduler.Duration; import io.redlink.more.data.model.scheduler.Interval; import io.redlink.more.data.model.scheduler.RelativeEvent; import io.redlink.more.data.model.scheduler.ScheduleEvent; @@ -41,6 +40,9 @@ public class StudyRepository { private static final String SQL_LIST_OBSERVATIONS_BY_STUDY = "SELECT * FROM observations WHERE study_id = ? AND ( study_group_id IS NULL OR study_group_id = ? )"; + private static final String SQL_LIST_OBSERVATIONS_BY_STUDY_WITH_ALL_OBSERVATIONS = + "SELECT * FROM observations WHERE study_id = ?"; + private static final String SQL_ROUTING_INFO_BY_REG_TOKEN = "SELECT pt.study_id as study_id, pt.participant_id as participant_id, study_group_id, s.status = 'active' as is_active " + "FROM participants pt " + @@ -101,6 +103,11 @@ public class StudyRepository { "JOIN studies s on p.study_id = s.study_id " + "WHERE p.study_id = ? AND participant_id = ?"; + private static final String GET_DURATION_INFO_FOR_STUDY = + "SELECT sg.study_group_id as groupid, sg.duration AS groupduration, s.duration AS studyduration, s.planned_end_date AS enddate, s.planned_start_date AS startdate FROM studies s " + + "LEFT OUTER JOIN study_groups sg on s.study_id = sg.study_id " + + "WHERE s.study_id = ?"; + private final JdbcTemplate jdbcTemplate; private final NamedParameterJdbcTemplate namedTemplate; @@ -152,8 +159,12 @@ public Optional findByRegistrationToken(String registrationToken) { } public Optional findStudy(RoutingInfo routingInfo) { + return findStudy(routingInfo, true); + } + + public Optional findStudy(RoutingInfo routingInfo, boolean filterObservationsByGroup) { final List observations = listObservations( - routingInfo.studyId(), routingInfo.studyGroupId().orElse(-1), routingInfo.participantId()); + routingInfo.studyId(), routingInfo.studyGroupId().orElse(-1), routingInfo.participantId(),filterObservationsByGroup); final SimpleParticipant participant = findParticipant(routingInfo).orElse(null); @@ -182,10 +193,16 @@ public Optional findParticipant(RoutingInfo routingInfo) { } } - private List listObservations(long studyId, int groupId, int participantId) { - return jdbcTemplate.query(SQL_LIST_OBSERVATIONS_BY_STUDY, getObservationRowMapper(), studyId, groupId).stream() - .map(o -> mergeParticipantProperties(o, studyId, participantId)) - .toList(); + private List listObservations(long studyId, int groupId, int participantId, boolean filterByGroup) { + if(filterByGroup) { + return jdbcTemplate.query(SQL_LIST_OBSERVATIONS_BY_STUDY, getObservationRowMapper(), studyId, groupId).stream() + .map(o -> mergeParticipantProperties(o, studyId, participantId)) + .toList(); + } else { + return jdbcTemplate.query(SQL_LIST_OBSERVATIONS_BY_STUDY_WITH_ALL_OBSERVATIONS, getObservationRowMapper(), studyId).stream() + .map(o -> mergeParticipantProperties(o, studyId, participantId)) + .toList(); + } } private Observation mergeParticipantProperties(Observation observation, long studyId, int participantId) { @@ -289,6 +306,7 @@ private static RowMapper getStudyRowMapper(List observations rs.getString("consent_info"), readContact(rs), toLocalDate(rs.getDate("start_date")), + toLocalDate(rs.getDate("planned_start_date")), toLocalDate(rs.getDate("planned_end_date")), observations, toInstant(rs.getTimestamp("created")), @@ -309,6 +327,7 @@ private static Contact readContact(ResultSet rs) throws SQLException { private static RowMapper getObservationRowMapper() { return (rs, rowNum) -> new Observation( rs.getInt("observation_id"), + rs.getInt("study_group_id"), rs.getString("title"), rs.getString("type"), rs.getString("participant_info"), @@ -380,4 +399,15 @@ public Interval getInterval(Long studyId, Integer participantId, RelativeEvent e return stream.findFirst().orElse(null); } } + + public Optional getStudyDurationInfo(Long studyId) { + return jdbcTemplate.query(GET_DURATION_INFO_FOR_STUDY, + ((rs, rowNum) -> new StudyDurationInfo() + .setEndDate(rs.getDate("enddate").toLocalDate()) + .setStartDate(rs.getDate("startdate").toLocalDate()) + .setDuration(DbUtils.readDuration(rs, "studyduration")) + .addGroupDuration(Pair.of(rs.getInt("groupid"), DbUtils.readDuration(rs, "groupduration")) + )), studyId).stream() + .reduce((prev, curr) -> prev.addGroupDuration(curr.getGroupDurations().get(0))); + } } diff --git a/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java index f5bbb19..a26acee 100644 --- a/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java +++ b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java @@ -56,11 +56,10 @@ private static Instant shiftStartIfNecessary(Instant start) { return start; } - public static Instant toInstant(RelativeDate date, Instant start) { + private static Instant toInstant(RelativeDate date, Instant start) { return ZonedDateTime.ofInstant(start.plus(date.getOffset().getValue() - 1L, date.getOffset().getUnit().toTemporalUnit()), ZoneId.systemDefault()) .withHour(date.getHours()) - .withMinute(date.getMinutes()) - .withSecond(date.getSeconds()).toInstant(); + .withMinute(date.getMinutes()).toInstant(); } public static List> parseToObservationSchedulesForEvent(Event event) { diff --git a/src/main/java/io/redlink/more/data/service/CalendarService.java b/src/main/java/io/redlink/more/data/service/CalendarService.java new file mode 100644 index 0000000..5946c61 --- /dev/null +++ b/src/main/java/io/redlink/more/data/service/CalendarService.java @@ -0,0 +1,63 @@ +package io.redlink.more.data.service; + +import biweekly.Biweekly; +import biweekly.ICalendar; +import biweekly.component.VEvent; +import io.redlink.more.data.model.Observation; +import io.redlink.more.data.model.RoutingInfo; +import io.redlink.more.data.model.StudyDurationInfo; +import io.redlink.more.data.repository.StudyRepository; +import io.redlink.more.data.schedule.SchedulerUtils; +import org.springframework.stereotype.Service; + +import java.sql.Date; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Optional; +import java.util.TimeZone; + +@Service +public class CalendarService { + private final StudyRepository studyRepository; + + public CalendarService(StudyRepository studyRepository) { + this.studyRepository = studyRepository; + } + + public Optional getICalendarString(Long studyId) { + + return studyRepository.findStudy(new RoutingInfo(studyId, -1, -1, false), false).map(study -> { + ICalendar ical = new ICalendar(); + + VEvent iCalEvent = new VEvent(); + iCalEvent.addCategories("General"); + iCalEvent.setSummary("Study: " + study.title()); + iCalEvent.setDateStart(Date.from(study.plannedStartDate().atStartOfDay(TimeZone.getDefault().toZoneId()).toInstant())); + iCalEvent.setDateEnd(Date.from(study.endDate().atStartOfDay(TimeZone.getDefault().toZoneId()).toInstant())); + ical.addEvent(iCalEvent); + + Instant start = study.plannedStartDate().atStartOfDay(ZoneId.systemDefault()).toInstant(); + + StudyDurationInfo info = studyRepository.getStudyDurationInfo(studyId) + .orElseThrow(() -> new RuntimeException("Cannot create calendar")); + + study.observations().forEach(o -> { + SchedulerUtils.parseToObservationSchedules( + o.observationSchedule(), start, info.getDurationFor(o.groupId()).getEnd(start) + ).forEach(p -> { + VEvent oEvent = new VEvent(); + oEvent.addCategories("Observation", o.groupId() != null ? ("Group" + o.groupId()) : "NoGroup"); + oEvent.setSummary(getSummaryFor(o)); + oEvent.setDateStart(Date.from(p.getLeft())); + oEvent.setDateEnd(Date.from(p.getRight())); + ical.addEvent(oEvent); + }); + }); + return Biweekly.write(ical).go(); + }); + } + + private String getSummaryFor(Observation o) { + return "(O" + o.observationId()+"G"+o.groupId()+")" + o.title(); + } +} diff --git a/src/main/resources/openapi/ExternalAPI.yaml b/src/main/resources/openapi/ExternalAPI.yaml index cf54a96..cd8ae01 100644 --- a/src/main/resources/openapi/ExternalAPI.yaml +++ b/src/main/resources/openapi/ExternalAPI.yaml @@ -35,6 +35,23 @@ paths: $ref: '#/components/responses/UnauthorizedApiKey' '404': description: not found + /calendar/studies/{studyId}/calendar.ics: + get: + tags: + - calendar + description: Get study calendar for study as iCal + operationId: getStudyCalendar + parameters: + - $ref: '#/components/parameters/StudyId' + responses: + '200': + description: Successfully returned study calendar + content: + text/calendar: + schema: + type: string + '404': + description: Not found components: schemas: @@ -78,6 +95,28 @@ components: schema: type: string description: The token to authorize sending external data + StudyId: + name: studyId + in: path + schema: + type: integer + format: int64 + readOnly: true + required: true + StudyGroupId: + name: studyGroupId + in: path + schema: + type: integer + format: int32 + required: true + ParticipantId: + name: participantId + in: path + schema: + type: integer + format: int32 + required: true responses: UnauthorizedApiKey: diff --git a/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java b/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java index 6999da4..9af0927 100644 --- a/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java +++ b/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java @@ -298,11 +298,11 @@ void testRelativeEvent() { .setDtstart( new RelativeDate() .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) - .setTime("10:00:00") + .setTime("10:00") ).setDtend( new RelativeDate() .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) - .setTime("11:30:00") + .setTime("11:30") ); Instant start = Instant.ofEpochSecond(1700118000); // Thursday, 30. November 2023 00:00:00 @@ -319,11 +319,11 @@ void testRelativeEventWithRecursion() { .setDtstart( new RelativeDate() .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) - .setTime("10:00:00") + .setTime("10:00") ).setDtend( new RelativeDate() .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) - .setTime("11:30:00") + .setTime("11:30") ).setRrrule( new RelativeRecurrenceRule() .setEndAfter(new Duration().setValue(10).setUnit(Duration.Unit.DAY)) @@ -344,11 +344,11 @@ void testRelativeEventWithRecursionLongRun() { .setDtstart( new RelativeDate() .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) - .setTime("10:00:00") + .setTime("10:00") ).setDtend( new RelativeDate() .setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)) - .setTime("11:30:00") + .setTime("11:30") ).setRrrule( new RelativeRecurrenceRule() .setEndAfter(new Duration().setValue(100).setUnit(Duration.Unit.DAY)) From eb57872d4de727afc3a076f7ec3d3fde4f99204e Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Fri, 17 Nov 2023 09:10:48 +0100 Subject: [PATCH 09/15] MORE2-5 merge with main --- .../java/io/redlink/more/data/model/scheduler/Event.java | 5 +---- .../io/redlink/more/data/model/scheduler/RecurrenceRule.java | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/main/java/io/redlink/more/data/model/scheduler/Event.java b/src/main/java/io/redlink/more/data/model/scheduler/Event.java index 7749551..0382b34 100644 --- a/src/main/java/io/redlink/more/data/model/scheduler/Event.java +++ b/src/main/java/io/redlink/more/data/model/scheduler/Event.java @@ -6,10 +6,7 @@ * Foerderung der wissenschaftlichen Forschung). * Licensed under the Elastic License 2.0. */ - -import biweekly.property.RecurrenceRule; -import io.redlink.more.data.model.scheduler.ScheduleEvent; - +package io.redlink.more.data.model.scheduler; import java.time.Instant; public class Event implements ScheduleEvent { diff --git a/src/main/java/io/redlink/more/data/model/scheduler/RecurrenceRule.java b/src/main/java/io/redlink/more/data/model/scheduler/RecurrenceRule.java index ae22d80..5505f41 100644 --- a/src/main/java/io/redlink/more/data/model/scheduler/RecurrenceRule.java +++ b/src/main/java/io/redlink/more/data/model/scheduler/RecurrenceRule.java @@ -1,4 +1,4 @@ -/* +package io.redlink.more.data.model.scheduler;/* * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute * for Digital Health and Prevention -- A research institute of the @@ -6,9 +6,6 @@ * Foerderung der wissenschaftlichen Forschung). * Licensed under the Elastic License 2.0. */ -package io.redlink.more.data.model; -package io.redlink.more.data.model.scheduler; - import java.time.Instant; import java.util.List; From 54e87ffd31b7f7bc3dc3c9a37118a83f430f815f Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Tue, 21 Nov 2023 15:05:23 +0100 Subject: [PATCH 10/15] TT-6 check if partipant is avtive --- .../io/redlink/more/data/repository/StudyRepository.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/redlink/more/data/repository/StudyRepository.java b/src/main/java/io/redlink/more/data/repository/StudyRepository.java index a679dea..4fe4e67 100644 --- a/src/main/java/io/redlink/more/data/repository/StudyRepository.java +++ b/src/main/java/io/redlink/more/data/repository/StudyRepository.java @@ -44,7 +44,7 @@ public class StudyRepository { "SELECT * FROM observations WHERE study_id = ?"; private static final String SQL_ROUTING_INFO_BY_REG_TOKEN = - "SELECT pt.study_id as study_id, pt.participant_id as participant_id, study_group_id, s.status = 'active' as is_active " + + "SELECT pt.study_id as study_id, pt.participant_id as participant_id, study_group_id, (s.status = 'active' AND pt.status = 'active') as is_active " + "FROM participants pt " + " INNER JOIN registration_tokens rt ON (pt.study_id = rt.study_id and pt.participant_id = rt.participant_id) " + " INNER JOIN studies s on (s.study_id = pt.study_id) " + @@ -180,7 +180,7 @@ public Optional findParticipant(RoutingInfo routingInfo) { .map(Timestamp::toInstant).orElse(null); Instant end = Optional.ofNullable(DbUtils.readDuration(rs, "duration")) .map(d -> d.getEnd(start)) - .orElse(Instant.ofEpochMilli(rs.getDate("endDate").getTime())); + .orElse(Instant.ofEpochMilli(rs.getDate("planned_end_date").getTime())); return new SimpleParticipant( rs.getInt("participant_id"), rs.getString("alias"), @@ -390,7 +390,7 @@ public Interval getInterval(Long studyId, Integer participantId, RelativeEvent e // TODO correct sql.Date to Instant with Time 0 ?! Instant end = Optional.ofNullable(DbUtils.readDuration(rs, "duration")) .map(d -> d.getEnd(start)) - .orElse(Instant.ofEpochMilli(rs.getDate("endDate").getTime())); + .orElse(Instant.ofEpochMilli(rs.getDate("planned_end_date").getTime())); return new Interval(start, SchedulerUtils.getEnd(event, start, end)); }), From acf614a5e20a6afb6a35d74ee0381bb14dbbcb9f Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Tue, 21 Nov 2023 15:43:39 +0100 Subject: [PATCH 11/15] TT-21 remove events schedules outside the study ranges --- .../io/redlink/more/data/schedule/SchedulerUtils.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java index 93f8a16..1399701 100644 --- a/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java +++ b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java @@ -70,7 +70,7 @@ private static Instant toInstant(RelativeDate date, Instant start) { .withMinute(date.getMinutes()).toInstant(); } - public static List> parseToObservationSchedulesForEvent(Event event) { + public static List> parseToObservationSchedulesForEvent(Event event, Instant start, Instant end) { List> observationSchedules = new ArrayList<>(); if(event.getDateStart() != null && event.getDateEnd() != null) { VEvent iCalEvent = parseToICalEvent(event); @@ -79,7 +79,9 @@ public static List> parseToObservationSchedulesForEvent(E while (it.hasNext()) { Instant ostart = it.next().toInstant(); Instant oend = ostart.plus(eventDuration, ChronoUnit.SECONDS); - observationSchedules.add(Pair.of(ostart, oend)); + if(ostart.isAfter(start) && oend.isBefore(end)) { + observationSchedules.add(Pair.of(ostart, oend)); + } } } // TODO edge cases if calculated days are not consecutive (e.g. first weekend -> first of month is a sunday) @@ -88,7 +90,7 @@ public static List> parseToObservationSchedulesForEvent(E public static List> parseToObservationSchedules(ScheduleEvent scheduleEvent, Instant start, Instant end) { if(Event.class.isAssignableFrom(scheduleEvent.getClass())) { - return parseToObservationSchedulesForEvent((Event) scheduleEvent); + return parseToObservationSchedulesForEvent((Event) scheduleEvent, start, end); } else { return parseToObservationSchedulesForRelativeEvent((RelativeEvent) scheduleEvent, start, end); } From 9ef059e5fb79a13e4febc4191a335b58d7ea78b8 Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Tue, 21 Nov 2023 15:54:20 +0100 Subject: [PATCH 12/15] TT-21 fix tests failing to to consideration of study boundings --- .../data/schedule/SchedulerUtilsTest.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java b/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java index b0bf8b9..4717724 100644 --- a/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java +++ b/src/test/java/io/redlink/more/data/schedule/SchedulerUtilsTest.java @@ -50,7 +50,7 @@ void testParseDailyEvent() { .setFreq("DAILY") .setInterval(1) .setCount(3)); - List> actualValues = SchedulerUtils.parseToObservationSchedules(eventCount, Instant.now(), Instant.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(eventCount, Instant.parse("2022-10-01T00:00:00.000Z"), Instant.parse("2023-10-01T00:00:00.000Z")); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); @@ -62,7 +62,7 @@ void testParseDailyEvent() { .setInterval(1) .setUntil(LocalDateTime.parse("2022-11-25 14:00:00", formatter).toInstant(ZoneOffset.UTC))); - actualValues = SchedulerUtils.parseToObservationSchedules(eventUntil, Instant.now(), Instant.now()); + actualValues = SchedulerUtils.parseToObservationSchedules(eventUntil, Instant.parse("2022-10-01T00:00:00.000Z"), Instant.parse("2023-10-01T00:00:00.000Z")); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -85,7 +85,7 @@ void testParseDailyEventWith30MinDuration() { .setFreq("DAILY") .setInterval(1) .setCount(3)); - List> actualValues = SchedulerUtils.parseToObservationSchedules(eventCount, Instant.now(), Instant.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(eventCount, Instant.parse("2022-10-01T00:00:00.000Z"), Instant.parse("2023-10-01T00:00:00.000Z")); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); @@ -97,7 +97,7 @@ void testParseDailyEventWith30MinDuration() { .setInterval(1) .setUntil(LocalDateTime.parse("2022-11-25 14:00:00", formatter).toInstant(ZoneOffset.UTC))); - actualValues = SchedulerUtils.parseToObservationSchedules(eventUntil, Instant.now(), Instant.now()); + actualValues = SchedulerUtils.parseToObservationSchedules(eventUntil, Instant.parse("2022-10-01T00:00:00.000Z"), Instant.parse("2023-10-01T00:00:00.000Z")); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @Test @@ -122,7 +122,7 @@ void testParseMonthlyEvent() { .setBySetPos(1) .setCount(3)); - List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.parse("2022-10-01T00:00:00.000Z"), Instant.parse("2023-10-01T00:00:00.000Z")); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -161,7 +161,7 @@ void testParseMonthlyEventByDays() { .setByDay(List.of(new String[]{"MO", "TU", "WE"})) .setBySetPos(1) .setCount(9)); - List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.parse("2022-10-01T00:00:00.000Z"), Instant.parse("2023-10-01T00:00:00.000Z")); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -185,7 +185,7 @@ void testParseWeeklyEvent() { .setInterval(1) .setByDay(List.of(new String[]{"WE"})) .setCount(3)); - List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.parse("2022-10-01T00:00:00.000Z"), Instant.parse("2023-10-01T00:00:00.000Z")); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -210,7 +210,7 @@ void testParseYearlyEvent() { .setByMonthDay(5) .setByMonth(12) .setCount(3)); - List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.parse("2022-10-01T00:00:00.000Z"), Instant.parse("2030-10-01T00:00:00.000Z")); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -236,7 +236,7 @@ void testParseYearlyEventBySetPos() { .setByDay(List.of(new String[]{"MO"})) .setByMonth(12) .setCount(3)); - List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.parse("2022-10-01T00:00:00.000Z"), Instant.parse("2030-10-01T00:00:00.000Z")); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -270,7 +270,7 @@ void testParseYearlyEventBySetPosAndByDays() { .setByDay(List.of(new String[]{"MO", "TU"})) .setByMonth(12) .setCount(6)); - List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.parse("2022-10-01T00:00:00.000Z"), Instant.parse("2030-10-01T00:00:00.000Z")); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } @@ -294,7 +294,7 @@ void testParseHourlyEvent() { .setFreq("HOURLY") .setInterval(2) .setUntil(LocalDateTime.parse("2022-12-05 20:00:00", formatter).toInstant(ZoneOffset.UTC))); - List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.now(), Instant.now()); + List> actualValues = SchedulerUtils.parseToObservationSchedules(event, Instant.parse("2022-10-01T00:00:00.000Z"), Instant.parse("2023-10-01T00:00:00.000Z")); assertArrayEquals(Arrays.stream(expectedValues.toArray()).map(Object::toString).toArray(), Arrays.stream(actualValues.toArray()).map(Object::toString).toArray()); } From 9bbd3ef986f76ed92ee90545131316c7305aa667 Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Wed, 22 Nov 2023 12:16:54 +0100 Subject: [PATCH 13/15] FEATURE make github actions ready for fork features --- .../workflows/compile-test-deploy-push.yml | 71 ------------------- .github/workflows/compile-test.yml | 36 +++++++++- 2 files changed, 35 insertions(+), 72 deletions(-) delete mode 100644 .github/workflows/compile-test-deploy-push.yml diff --git a/.github/workflows/compile-test-deploy-push.yml b/.github/workflows/compile-test-deploy-push.yml deleted file mode 100644 index 7e2fed9..0000000 --- a/.github/workflows/compile-test-deploy-push.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Test, Deploy and Push Image -on: - workflow_dispatch: - -jobs: - Compile-and-Test: - name: Compile and Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'temurin' - java-version: 17 - - name: Compile and test project - run: ./mvnw -B -U - --no-transfer-progress - compile test - - name: Show 3rd-Party Licenses - run: | - cat ./target/generated-sources/license/THIRD-PARTY.txt - - name: Upload Test Results - if: always() - uses: actions/upload-artifact@v3 - with: - name: Test Results - path: "**/TEST-*.xml" - - name: Upload Licenses List - if: always() - uses: actions/upload-artifact@v3 - with: - name: Licenses List - path: "./target/generated-sources/license/THIRD-PARTY.txt" - - Build-and-Deploy: - name: "Build and Push Docker Image" - runs-on: ubuntu-latest - needs: - - Compile-and-Test - steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'temurin' - java-version: 17 - - name: Build JIB container and publish to GitHub Packages - run: ./mvnw -B -U - --no-transfer-progress - clean verify jib:build - -Drevision=${{github.run_number}} - -Dchangelist= - -Dsha1=.${GITHUB_SHA:0:7} - -Dquick - -Ddocker.namespace=${DOCKER_NAMESPACE,,} - -Djib.to.tags=latest - -Djib.to.auth.username=${{ github.actor }} - -Djib.to.auth.password=${{ secrets.GITHUB_TOKEN }} - env: - DOCKER_NAMESPACE: ghcr.io/${{ github.repository_owner }} - - event_file: - name: "Event File" - runs-on: ubuntu-latest - steps: - - name: Upload - uses: actions/upload-artifact@v3 - with: - name: Event File - path: ${{ github.event_path }} \ No newline at end of file diff --git a/.github/workflows/compile-test.yml b/.github/workflows/compile-test.yml index 5360e15..3af533b 100644 --- a/.github/workflows/compile-test.yml +++ b/.github/workflows/compile-test.yml @@ -1,6 +1,10 @@ name: Test and Compile on: workflow_dispatch: + inputs: + dockerTag: + description: If set, docker img is built and tagged accordingly + required: false push: jobs: @@ -34,6 +38,36 @@ jobs: name: Licenses List path: "./target/generated-sources/license/THIRD-PARTY.txt" + Build-and-Deploy: + name: "Build and Push Docker Image" + runs-on: ubuntu-latest + if: github.ref_name == 'main' || github.event.inputs.dockerTag != '' + needs: + - Compile-and-Test + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: 17 + - name: Build JIB container and publish to GitHub Packages + run: + TAG=${{github.event.inputs.dockerTag}} && + ./mvnw -B -U + --no-transfer-progress + clean verify jib:build + -Drevision=${{github.run_number}} + -Dchangelist= + -Dsha1=.${GITHUB_SHA:0:7} + -Dquick + -Ddocker.namespace=${DOCKER_NAMESPACE,,} + -Djib.to.tags=${TAG:=latest} + -Djib.to.auth.username=${{ github.actor }} + -Djib.to.auth.password=${{ secrets.GITHUB_TOKEN }} + env: + DOCKER_NAMESPACE: ghcr.io/${{ github.repository_owner }} + event_file: name: "Event File" runs-on: ubuntu-latest @@ -42,4 +76,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: Event File - path: ${{ github.event_path }} \ No newline at end of file + path: ${{ github.event_path }} From 62bed70762ba55966945c8617faaec39a05ab729 Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Wed, 22 Nov 2023 19:31:53 +0100 Subject: [PATCH 14/15] FEATURE improve cal export --- .../java/io/redlink/more/data/service/CalendarService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/redlink/more/data/service/CalendarService.java b/src/main/java/io/redlink/more/data/service/CalendarService.java index 5946c61..84e55f8 100644 --- a/src/main/java/io/redlink/more/data/service/CalendarService.java +++ b/src/main/java/io/redlink/more/data/service/CalendarService.java @@ -32,8 +32,8 @@ public Optional getICalendarString(Long studyId) { VEvent iCalEvent = new VEvent(); iCalEvent.addCategories("General"); iCalEvent.setSummary("Study: " + study.title()); - iCalEvent.setDateStart(Date.from(study.plannedStartDate().atStartOfDay(TimeZone.getDefault().toZoneId()).toInstant())); - iCalEvent.setDateEnd(Date.from(study.endDate().atStartOfDay(TimeZone.getDefault().toZoneId()).toInstant())); + iCalEvent.setDateStart(Date.from(study.plannedStartDate().atStartOfDay(TimeZone.getDefault().toZoneId()).toInstant()), false); + iCalEvent.setDateEnd(Date.from(study.endDate().atStartOfDay(TimeZone.getDefault().toZoneId()).toInstant()), false); ical.addEvent(iCalEvent); Instant start = study.plannedStartDate().atStartOfDay(ZoneId.systemDefault()).toInstant(); From 042b2f57f0f9e205ef282ef08aa5d45dad44cc69 Mon Sep 17 00:00:00 2001 From: Thomas Kurz Date: Thu, 23 Nov 2023 15:45:05 +0100 Subject: [PATCH 15/15] FEATURE set abs or rel to calendar summary --- .../more/data/schedule/SchedulerUtils.java | 6 ++---- .../more/data/service/CalendarService.java | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java index 1399701..00e4870 100644 --- a/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java +++ b/src/main/java/io/redlink/more/data/schedule/SchedulerUtils.java @@ -20,10 +20,7 @@ import java.time.*; import java.time.Duration; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.TimeZone; +import java.util.*; public class SchedulerUtils { @@ -89,6 +86,7 @@ public static List> parseToObservationSchedulesForEvent(E } public static List> parseToObservationSchedules(ScheduleEvent scheduleEvent, Instant start, Instant end) { + if(scheduleEvent == null) return Collections.emptyList(); if(Event.class.isAssignableFrom(scheduleEvent.getClass())) { return parseToObservationSchedulesForEvent((Event) scheduleEvent, start, end); } else { diff --git a/src/main/java/io/redlink/more/data/service/CalendarService.java b/src/main/java/io/redlink/more/data/service/CalendarService.java index 84e55f8..60d85ab 100644 --- a/src/main/java/io/redlink/more/data/service/CalendarService.java +++ b/src/main/java/io/redlink/more/data/service/CalendarService.java @@ -6,6 +6,7 @@ import io.redlink.more.data.model.Observation; import io.redlink.more.data.model.RoutingInfo; import io.redlink.more.data.model.StudyDurationInfo; +import io.redlink.more.data.model.scheduler.Event; import io.redlink.more.data.repository.StudyRepository; import io.redlink.more.data.schedule.SchedulerUtils; import org.springframework.stereotype.Service; @@ -42,12 +43,13 @@ public Optional getICalendarString(Long studyId) { .orElseThrow(() -> new RuntimeException("Cannot create calendar")); study.observations().forEach(o -> { + String type = getTpeFor(o); SchedulerUtils.parseToObservationSchedules( o.observationSchedule(), start, info.getDurationFor(o.groupId()).getEnd(start) ).forEach(p -> { VEvent oEvent = new VEvent(); oEvent.addCategories("Observation", o.groupId() != null ? ("Group" + o.groupId()) : "NoGroup"); - oEvent.setSummary(getSummaryFor(o)); + oEvent.setSummary(getSummaryFor(o, type)); oEvent.setDateStart(Date.from(p.getLeft())); oEvent.setDateEnd(Date.from(p.getRight())); ical.addEvent(oEvent); @@ -57,7 +59,14 @@ public Optional getICalendarString(Long studyId) { }); } - private String getSummaryFor(Observation o) { - return "(O" + o.observationId()+"G"+o.groupId()+")" + o.title(); + private String getTpeFor(Observation o) { + if(o.observationSchedule() != null && Event.class.isAssignableFrom(o.observationSchedule().getClass())) { + return "ABS"; + } + return "REL"; + } + + private String getSummaryFor(Observation o, String t) { + return "(O" + o.observationId()+"G"+o.groupId()+"|"+t+") " + o.title(); } }