From 7eeff8ad8038d4ab197f32213ea91d8b6d77d3bc Mon Sep 17 00:00:00 2001 From: Rupert Westenthaler Date: Tue, 16 Dec 2025 08:58:28 +0100 Subject: [PATCH 1/4] #180: This adds observation groups to the MORE study manager backend. It does not yet use observation groups in business logic (e.g. schedule calculation). It also does not yet provide support for observation groups for import and export of studies * Observation Groups as new Entity * does have a title and a purpose * Observation and Interventions can have an optional observation group (0..1) * Participants can be optionally assigned to observation groups (0..n) * New ObservationGroupsService with an API to CRUD Observation Groups * Extended API for Observation, Intervention and Participant for Observation Groups * it is now possible to include Observation Groups in Group based lookups for Observations and Interventions in a Study. The semantic is the same as for StudyGroups. * New `/studies/{studyId}/observationGroups` endpoint with full CRUD functionality * Extended endpoints for observation, intervention and participant to provide the observation group ids for the requested entities This also include Component Tests for the Repository. Extends Repository Tests of Observation, Intervention and Participants for the new functionalities. Adds UnitTests for the new Controller and extends UnitTests for the Participant, Observation and Intervention Controller to validate the correct mappings of the new observation group properties --- pom.xml | 17 +- .../lime/LimeSurveyObservation.java | 4 +- .../lime/LimeSurveyObservationFactory.java | 4 +- studymanager-services/pom.xml | 6 +- .../exception/NotFoundException.java | 3 + .../more/studymanager/model/Intervention.java | 10 + .../more/studymanager/model/Observation.java | 10 + .../studymanager/model/ObservationGroup.java | 76 ++++++ .../more/studymanager/model/Participant.java | 25 ++ .../repository/InterventionRepository.java | 28 ++- .../ObservationGroupRepository.java | 114 +++++++++ .../repository/ObservationRepository.java | 61 ++++- .../repository/ParticipantRepository.java | 64 ++++- .../repository/RepositoryUtils.java | 40 +++- .../service/ObservationGroupService.java | 58 +++++ .../V1_19_0__add_observation_groups.sql | 51 ++++ .../InterventionRepositoryTest.java | 112 +++++++-- .../ObservationGroupRepositoryTest.java | 125 ++++++++++ .../repository/ObservationRepositoryTest.java | 105 +++++++-- .../repository/ParticipantRepositoryTest.java | 53 +++-- studymanager/pom.xml | 6 +- .../ObservationGroupApiV1Controller.java | 98 ++++++++ .../transformer/InterventionTransformer.java | 4 +- .../ObservationGroupTransformer.java | 43 ++++ .../transformer/ObservationTransformer.java | 6 +- .../transformer/ParticipantTransformer.java | 4 +- .../resources/openapi/StudyManagerAPI.yaml | 143 ++++++++++++ .../InterventionControllerTest.java | 7 +- .../ObservationControllerTest.java | 9 +- .../ObservationGroupControllerTest.java | 219 ++++++++++++++++++ .../ParticipantControllerTest.java | 12 +- 31 files changed, 1402 insertions(+), 115 deletions(-) create mode 100644 studymanager-services/src/main/java/io/redlink/more/studymanager/model/ObservationGroup.java create mode 100644 studymanager-services/src/main/java/io/redlink/more/studymanager/repository/ObservationGroupRepository.java create mode 100644 studymanager-services/src/main/java/io/redlink/more/studymanager/service/ObservationGroupService.java create mode 100644 studymanager-services/src/main/resources/db/migration/V1_19_0__add_observation_groups.sql create mode 100644 studymanager-services/src/test/java/io/redlink/more/studymanager/repository/ObservationGroupRepositoryTest.java create mode 100644 studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ObservationGroupApiV1Controller.java create mode 100644 studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ObservationGroupTransformer.java create mode 100644 studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ObservationGroupControllerTest.java diff --git a/pom.xml b/pom.xml index ee9d4bc0..59bc5de5 100644 --- a/pom.xml +++ b/pom.xml @@ -40,8 +40,7 @@ 17 3.5.5 - 1.21.3 - + 2.0.2 more-project io.redlink.more.studymanager.Application @@ -404,20 +403,20 @@ - org.springframework.boot - spring-boot-dependencies - ${spring-boot.version} + org.testcontainers + testcontainers-bom + ${testcontainers.version} pom import - - org.testcontainers - testcontainers-bom - ${testcontainers.version} + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} pom import + co.elastic.clients elasticsearch-java diff --git a/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservation.java b/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservation.java index 63953304..de45c43d 100644 --- a/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservation.java +++ b/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservation.java @@ -37,12 +37,14 @@ public LimeSurveyObservation(MoreObservationSDK sdk, C properties, LimeSurveyReq public void activate(){ String surveyId = checkAndGetSurveyId(); + //FIXME: This creates a LIME survey user for every participant, regardless if the Observation is relevant to the participant Set participantIds = sdk.participantIds(MorePlatformSDK.ParticipantFilter.ALL); + //FIXME: Check for the expected keys (token and limeUrl) and not just if any properties are present! participantIds.removeIf(id -> sdk.getPropertiesForParticipant(id).isPresent()); limeSurveyRequestService.activateParticipants(participantIds, surveyId) .forEach(data -> sdk.setPropertiesForParticipant( - Integer.parseInt(data.firstname()), + Integer.parseInt(data.firstname()), //NOTE: both the firstname and lastname are set to the participantId new ObservationProperties( Map.of("token", data.token(), "limeUrl", limeSurveyRequestService.getBaseUrl()) diff --git a/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservationFactory.java b/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservationFactory.java index a4d158e4..5c7ca126 100644 --- a/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservationFactory.java +++ b/studymanager-observation/src/main/java/io/redlink/more/studymanager/component/observation/lime/LimeSurveyObservationFactory.java @@ -46,11 +46,13 @@ public class LimeSurveyObservationFactory, P public LimeSurveyObservationFactory() {} - public LimeSurveyObservationFactory( + //FIXME: Try to get rid of constructors only used by UnitTests to Mock the LimeSurveyRequestService + LimeSurveyObservationFactory( ComponentFactoryProperties properties, LimeSurveyRequestService limeSurveyRequestService) { this.componentProperties = properties; this.limeSurveyRequestService = limeSurveyRequestService; } + @Override public LimeSurveyObservationFactory init(ComponentFactoryProperties componentProperties){ this.componentProperties = componentProperties; diff --git a/studymanager-services/pom.xml b/studymanager-services/pom.xml index 8e01ee3f..080db993 100644 --- a/studymanager-services/pom.xml +++ b/studymanager-services/pom.xml @@ -139,17 +139,17 @@ org.testcontainers - junit-jupiter + testcontainers-junit-jupiter test org.testcontainers - postgresql + testcontainers-postgresql test org.testcontainers - elasticsearch + testcontainers-elasticsearch test diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/exception/NotFoundException.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/exception/NotFoundException.java index 447805c8..7a93bc00 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/exception/NotFoundException.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/exception/NotFoundException.java @@ -28,6 +28,9 @@ public static NotFoundException Study(long id) { public static NotFoundException StudyGroup(long studyId, int studyGroupId) { return new NotFoundException("StudyGroup", studyId + "/" + studyGroupId); } + public static NotFoundException ObservationGroup(long studyId, int observationGroupId) { + return new NotFoundException("ObservationGroup", studyId + "/" + observationGroupId); + } public static NotFoundException Participant(long studyId, int participantId) { return new NotFoundException("Participant", studyId + "/" + participantId); diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/model/Intervention.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/model/Intervention.java index f8f26670..fe4a70b0 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/model/Intervention.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/model/Intervention.java @@ -21,6 +21,7 @@ public class Intervention { private ScheduleEvent schedule; private Instant created; private Instant modified; + private Integer observationGroupId; public Long getStudyId() { return studyId; @@ -67,6 +68,15 @@ public Intervention setStudyGroupId(Integer studyGroupId) { return this; } + public Integer getObservationGroupId() { + return observationGroupId; + } + + public Intervention setObservationGroupId(Integer observationGroupId) { + this.observationGroupId = observationGroupId; + return this; + } + public ScheduleEvent getSchedule() { return schedule; } diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/model/Observation.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/model/Observation.java index fdcbc68a..879a37fe 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/model/Observation.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/model/Observation.java @@ -27,6 +27,7 @@ public class Observation { private Instant modified; private Boolean hidden; private Boolean noSchedule = false; + private Integer observationGroupId; public Long getStudyId() { return studyId; @@ -91,6 +92,15 @@ public Observation setStudyGroupId(Integer studyGroupId) { return this; } + public Integer getObservationGroupId() { + return observationGroupId; + } + + public Observation setObservationGroupId(Integer observationGroupId) { + this.observationGroupId = observationGroupId; + return this; + } + public ObservationProperties getProperties() { return properties; } diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/model/ObservationGroup.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/model/ObservationGroup.java new file mode 100644 index 00000000..143375db --- /dev/null +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/model/ObservationGroup.java @@ -0,0 +1,76 @@ +/* + * 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 + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Elastic License 2.0. + */ +package io.redlink.more.studymanager.model; + +import io.redlink.more.studymanager.model.scheduler.Duration; + +import java.time.Instant; + +public class ObservationGroup { + private Long studyId; + private Integer observationGroupId; + private String title; + private String purpose; + private Instant created; + private Instant modified; + + public Long getStudyId() { + return studyId; + } + + public ObservationGroup setStudyId(Long studyId) { + this.studyId = studyId; + return this; + } + + public Integer getObservationGroupId() { + return observationGroupId; + } + + public ObservationGroup setObservationGroupId(Integer observationGroupId) { + this.observationGroupId = observationGroupId; + return this; + } + + public String getTitle() { + return title; + } + + public ObservationGroup setTitle(String title) { + this.title = title; + return this; + } + + public String getPurpose() { + return purpose; + } + + public ObservationGroup setPurpose(String purpose) { + this.purpose = purpose; + return this; + } + + public Instant getCreated() { + return created; + } + + public ObservationGroup setCreated(Instant created) { + this.created = created; + return this; + } + + public Instant getModified() { + return modified; + } + + public ObservationGroup setModified(Instant modified) { + this.modified = modified; + return this; + } +} diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/model/Participant.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/model/Participant.java index cc4f9d47..79928381 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/model/Participant.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/model/Participant.java @@ -8,6 +8,9 @@ */ package io.redlink.more.studymanager.model; import java.time.Instant; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; public class Participant { private Long studyId; @@ -19,6 +22,7 @@ public class Participant { private Instant modified; private Instant start; private String registrationToken; + private Set observationGroupIds = new HashSet<>(); public Long getStudyId() { return this.studyId; @@ -92,6 +96,27 @@ public Participant setStudyGroupId(Integer studyGroupId) { return this; } + public Participant setObservationGroupIds(Set observationGroupIds) { + this.observationGroupIds = observationGroupIds == null ? new HashSet<>() : observationGroupIds; + return this; + } + + public Set getObservationGroupIds() { + return observationGroupIds; + } + + public Participant addObservationGroupId(Integer observationGroupId) { + if(observationGroupIds != null) { + this.observationGroupIds.add(observationGroupId); + } + return this; + } + + public Participant removeObservationGroupId(Integer observationGroupId) { + this.observationGroupIds.remove(observationGroupId); + return this; + } + public Instant getCreated() { return created; } diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java index 03788160..b6b473a2 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java @@ -24,6 +24,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Component; +import java.util.Collection; import java.util.List; import static io.redlink.more.studymanager.repository.RepositoryUtils.getValidNullableIntegerValue; @@ -31,14 +32,14 @@ @Component public class InterventionRepository { - private static final String INSERT_INTERVENTION = "INSERT INTO interventions(study_id,intervention_id,title,purpose,study_group_id,schedule) VALUES (:study_id,(SELECT COALESCE(MAX(intervention_id),0)+1 FROM interventions WHERE study_id = :study_id),:title,:purpose,:study_group_id,:schedule::jsonb) RETURNING *"; - private static final String IMPORT_INTERVENTION = "INSERT INTO interventions(study_id,intervention_id,title,purpose,study_group_id,schedule) VALUES (:study_id,:intervention_id,:title,:purpose,:study_group_id,:schedule::jsonb) RETURNING *"; + private static final String INSERT_INTERVENTION = "INSERT INTO interventions(study_id,intervention_id,title,purpose,study_group_id,schedule,observation_group_id) VALUES (:study_id,(SELECT COALESCE(MAX(intervention_id),0)+1 FROM interventions WHERE study_id = :study_id),:title,:purpose,:study_group_id,:schedule::jsonb,:observation_group_id) RETURNING *"; + private static final String IMPORT_INTERVENTION = "INSERT INTO interventions(study_id,intervention_id,title,purpose,study_group_id,schedule,observation_group_id) VALUES (:study_id,:intervention_id,:title,:purpose,:study_group_id,:schedule::jsonb,:observation_group_id) RETURNING *"; private static final String GET_INTERVENTION_BY_IDS = "SELECT * FROM interventions WHERE study_id = ? AND intervention_id = ?"; private static final String LIST_INTERVENTIONS = "SELECT * FROM interventions WHERE study_id = ?"; - private static final String LIST_INTERVENTIONS_FOR_GROUP = "SELECT * FROM interventions WHERE study_id = :study_id AND (study_group_id IS NULL OR study_group_id = :study_group_id)"; + private static final String LIST_INTERVENTIONS_FOR_GROUP = "SELECT * FROM interventions WHERE study_id = :study_id AND (study_group_id IS NULL OR study_group_id = :study_group_id) AND (observation_group_id IS NULL OR observation_group_id = ANY(:observation_group_ids::INT[]))"; private static final String DELETE_INTERVENTION_BY_IDS = "DELETE FROM interventions WHERE study_id = ? AND intervention_id = ?"; private static final String DELETE_ALL = "DELETE FROM interventions"; - private static final String UPDATE_INTERVENTION = "UPDATE interventions SET title=:title, study_group_id=:study_group_id, purpose=:purpose, schedule=:schedule::jsonb WHERE study_id=:study_id AND intervention_id=:intervention_id"; + private static final String UPDATE_INTERVENTION = "UPDATE interventions SET title=:title, study_group_id=:study_group_id, purpose=:purpose, schedule=:schedule::jsonb, observation_group_id=:observation_group_id WHERE study_id=:study_id AND intervention_id=:intervention_id"; private static final String CREATE_ACTION = "INSERT INTO actions(study_id,intervention_id,action_id,type,properties) VALUES (:study_id,:intervention_id,(SELECT COALESCE(MAX(action_id),0)+1 FROM actions WHERE study_id = :study_id AND intervention_id=:intervention_id),:type,:properties::jsonb) RETURNING *"; private static final String IMPORT_ACTION = "INSERT INTO actions(study_id,intervention_id,action_id,type,properties) VALUES (:study_id,:intervention_id,:action_id,:type,:properties::jsonb) RETURNING *"; private static final String GET_ACTION_BY_IDS = "SELECT * FROM actions WHERE study_id=? AND intervention_id=? AND action_id=?"; @@ -60,7 +61,7 @@ public Intervention insert(Intervention intervention) { try { return namedTemplate.queryForObject(INSERT_INTERVENTION, interventionToParams(intervention), getInterventionRowMapper()); } catch (DataIntegrityViolationException e) { - throw new BadRequestException("Study group " + intervention.getStudyGroupId() + " does not exist on study " + intervention.getStudyId()); + throw new BadRequestException("Unable to insert Invervention because it refers an not existing group (Study group " + intervention.getStudyGroupId() + " and/or Observation group " + intervention.getObservationGroupId() + " does not exist on study " + intervention.getStudyId()); } } @@ -76,7 +77,8 @@ public Intervention importIntervention(Long studyId, Intervention intervention) "Error during import of intervention " + intervention.getInterventionId() + "for study " + - intervention.getStudyId() + intervention.getStudyId() + + "(" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")" ); } } @@ -85,10 +87,14 @@ public List listInterventions(Long studyId) { return template.query(LIST_INTERVENTIONS, getInterventionRowMapper(), studyId); } - public List listInterventionsForGroup(Long studyId, Integer groupId) { + public List listInterventionsForGroup(Long studyId, Integer groupId){ + return listInterventionsForGroup(studyId, groupId, List.of()); + } + public List listInterventionsForGroup(Long studyId, Integer groupId, Collection observationGroupIds) { return namedTemplate.query(LIST_INTERVENTIONS_FOR_GROUP, new MapSqlParameterSource("study_id", studyId) - .addValue("study_group_id", groupId), + .addValue("study_group_id", groupId) + .addValue("observation_group_ids", observationGroupIds == null ? new Integer[0] : observationGroupIds.toArray(new Integer[0])), getInterventionRowMapper() ); } @@ -190,7 +196,8 @@ private static MapSqlParameterSource interventionToParams(Intervention intervent .addValue("title", intervention.getTitle()) .addValue("purpose", intervention.getPurpose()) .addValue("study_group_id", intervention.getStudyGroupId()) - .addValue("schedule", MapperUtils.writeValueAsString(intervention.getSchedule())); + .addValue("schedule", MapperUtils.writeValueAsString(intervention.getSchedule())) + .addValue("observation_group_id", intervention.getObservationGroupId()); } private static MapSqlParameterSource triggerToParams(Long studyId, Integer interventionId, Trigger trigger) { @@ -235,6 +242,7 @@ private static RowMapper getInterventionRowMapper() { .setSchedule(MapperUtils.readValue(rs.getString("schedule"), Event.class)) .setStudyGroupId(getValidNullableIntegerValue(rs, "study_group_id")) .setCreated(RepositoryUtils.readInstant(rs,"created")) - .setModified(RepositoryUtils.readInstant(rs,"modified")); + .setModified(RepositoryUtils.readInstant(rs,"modified")) + .setObservationGroupId(getValidNullableIntegerValue(rs, "observation_group_id")); } } diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/ObservationGroupRepository.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/ObservationGroupRepository.java new file mode 100644 index 00000000..5e2ebfdc --- /dev/null +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/ObservationGroupRepository.java @@ -0,0 +1,114 @@ +/* + * 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 + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Elastic License 2.0. + */ +package io.redlink.more.studymanager.repository; + +import io.redlink.more.studymanager.exception.BadRequestException; +import io.redlink.more.studymanager.model.ObservationGroup; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class ObservationGroupRepository { + private static final String INSERT_OBSERVATION_GROUP = "INSERT INTO observation_groups (study_id,observation_group_id,title,purpose) VALUES (:study_id,(SELECT COALESCE(MAX(observation_group_id),0)+1 FROM observation_groups WHERE study_id = :study_id),:title,:purpose) RETURNING *"; + private static final String IMPORT_OBSERVATION_GROUP = "INSERT INTO observation_groups (study_id, observation_group_id, title, purpose) VALUES (:study_id,:observation_group_id,:title,:purpose) RETURNING *"; + private static final String GET_OBSERVATION_GROUP_BY_IDS = "SELECT * FROM observation_groups WHERE study_id = ? AND observation_group_id = ?"; + private static final String LIST_OBSERVATION_GROUPS_ORDER_BY_OBSERVATION_GROUP_ID = "SELECT * FROM observation_groups WHERE study_id = ? ORDER BY observation_group_id"; + private static final String UPDATE_OBSERVATION_GROUP = + "UPDATE observation_groups SET title = :title, purpose = :purpose, modified = now() WHERE study_id = :study_id AND observation_group_id = :observation_group_id"; + + private static final String DELETE_OBSERVATION_GROUP_BY_ID = "DELETE FROM observation_groups WHERE study_id = ? AND observation_group_id = ?"; + private static final String CLEAR_OBSERVATION_GROUPS = "DELETE FROM observation_groups"; + + private final JdbcTemplate template; + private final NamedParameterJdbcTemplate namedTemplate; + + public ObservationGroupRepository(JdbcTemplate template) { + this.template = template; + this.namedTemplate = new NamedParameterJdbcTemplate(template); + } + + public ObservationGroup insert(ObservationGroup observationGroup) { + try { + return namedTemplate.queryForObject(INSERT_OBSERVATION_GROUP, toParams(observationGroup), getObservationGroupRowMapper()); + } catch (DataIntegrityViolationException e) { + throw new BadRequestException("Study " + observationGroup.getStudyId() + " does not exist"); + } + } + + public ObservationGroup doImport(Long studyId, ObservationGroup observationGroup) { + try { + return namedTemplate.queryForObject( + IMPORT_OBSERVATION_GROUP, + toParams(observationGroup) + .addValue("study_id", studyId) + .addValue("observation_group_id", observationGroup.getObservationGroupId()), + getObservationGroupRowMapper() + ); + } catch (DataIntegrityViolationException e) { + throw new BadRequestException( + "Error during import of observationGroup " + + observationGroup.getObservationGroupId() + + "for study " + + observationGroup.getStudyId() + ); + } + } + + public ObservationGroup getByIds(long studyId, int observationGroupId) { + try { + return template.queryForObject(GET_OBSERVATION_GROUP_BY_IDS, getObservationGroupRowMapper(), studyId, observationGroupId); + } catch (EmptyResultDataAccessException e) { + return null; + } + } + + public List listObservationGroupsOrderedByObservationGroupIdAsc(long studyId) { + return template.query(LIST_OBSERVATION_GROUPS_ORDER_BY_OBSERVATION_GROUP_ID, getObservationGroupRowMapper(), studyId); + } + + public ObservationGroup update(ObservationGroup studyGroup) { + namedTemplate.update(UPDATE_OBSERVATION_GROUP, + toParams(studyGroup).addValue("observation_group_id", studyGroup.getObservationGroupId()) + ); + return getByIds(studyGroup.getStudyId(), studyGroup.getObservationGroupId()); + } + + public void deleteById(long studyId, int observationGroupId) { + template.update(DELETE_OBSERVATION_GROUP_BY_ID, studyId, observationGroupId); + } + + private static MapSqlParameterSource toParams(ObservationGroup observationGroup) { + return new MapSqlParameterSource() + .addValue("study_id", observationGroup.getStudyId()) + .addValue("title", observationGroup.getTitle()) + .addValue("purpose", observationGroup.getPurpose()); + } + + private static RowMapper getObservationGroupRowMapper() { + return (rs, rowNum) -> new ObservationGroup() + .setStudyId(rs.getLong("study_id")) + .setObservationGroupId(rs.getInt("observation_group_id")) + .setTitle(rs.getString("title")) + .setPurpose(rs.getString("purpose")) + .setCreated(RepositoryUtils.readInstant(rs, "created")) + .setModified(RepositoryUtils.readInstant(rs, "modified")); + } + + // for testing purpose only + protected void clear() { + template.execute(CLEAR_OBSERVATION_GROUPS); + } +} diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/ObservationRepository.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/ObservationRepository.java index 7b641f11..65b976b6 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/ObservationRepository.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/ObservationRepository.java @@ -14,9 +14,13 @@ import io.redlink.more.studymanager.model.Observation; import io.redlink.more.studymanager.model.scheduler.ScheduleEvent; import io.redlink.more.studymanager.utils.MapperUtils; + +import java.util.Collection; import java.util.List; import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; @@ -30,13 +34,15 @@ @Component public class ObservationRepository { - private static final String INSERT_NEW_OBSERVATION = "INSERT INTO observations(study_id,observation_id,title,purpose,participant_info,type,study_group_id,properties,schedule,hidden,no_schedule) VALUES (:study_id,(SELECT COALESCE(MAX(observation_id),0)+1 FROM observations WHERE study_id = :study_id),:title,:purpose,:participant_info,:type,:study_group_id,:properties::jsonb,:schedule::jsonb,:hidden,:no_schedule) RETURNING *"; - private static final String IMPORT_OBSERVATION = "INSERT INTO observations(study_id,observation_id,title,purpose,participant_info,type,study_group_id,properties,schedule,hidden,no_schedule) VALUES (:study_id,:observation_id,:title,:purpose,:participant_info,:type,:study_group_id,:properties::jsonb,:schedule::jsonb,:hidden,:no_schedule) RETURNING *"; + private final static Logger LOG = LoggerFactory.getLogger(ObservationRepository.class); + + private static final String INSERT_NEW_OBSERVATION = "INSERT INTO observations(study_id,observation_id,title,purpose,participant_info,type,study_group_id,properties,schedule,hidden,no_schedule,observation_group_id) VALUES (:study_id,(SELECT COALESCE(MAX(observation_id),0)+1 FROM observations WHERE study_id = :study_id),:title,:purpose,:participant_info,:type,:study_group_id,:properties::jsonb,:schedule::jsonb,:hidden,:no_schedule,:observation_group_id) RETURNING *"; + private static final String IMPORT_OBSERVATION = "INSERT INTO observations(study_id,observation_id,title,purpose,participant_info,type,study_group_id,properties,schedule,hidden,no_schedule,observation_group_id) VALUES (:study_id,:observation_id,:title,:purpose,:participant_info,:type,:study_group_id,:properties::jsonb,:schedule::jsonb,:hidden,:no_schedule,:observation_group_id) RETURNING *"; private static final String GET_OBSERVATION_BY_IDS = "SELECT * FROM observations WHERE study_id = ? AND observation_id = ?"; private static final String DELETE_BY_IDS = "DELETE FROM observations WHERE study_id = ? AND observation_id = ?"; private static final String LIST_OBSERVATIONS = "SELECT * FROM observations WHERE study_id = :study_id"; - private static final String LIST_OBSERVATIONS_FOR_GROUP = "SELECT * FROM observations WHERE study_id = :study_id AND (study_group_id IS NULL OR study_group_id = :study_group_id)"; - private static final String UPDATE_OBSERVATION = "UPDATE observations SET title=:title, purpose=:purpose, participant_info=:participant_info, study_group_id=:study_group_id, properties=:properties::jsonb, schedule=:schedule::jsonb, modified=now(), hidden=:hidden, no_schedule=:no_schedule WHERE study_id=:study_id AND observation_id=:observation_id"; + private static final String LIST_OBSERVATIONS_FOR_GROUP = "SELECT * FROM observations WHERE study_id = :study_id AND (study_group_id IS NULL OR study_group_id = :study_group_id) AND (observation_group_id IS NULL OR observation_group_id = ANY(:observation_group_ids::INT[]))"; + private static final String UPDATE_OBSERVATION = "UPDATE observations SET title=:title, purpose=:purpose, participant_info=:participant_info, study_group_id=:study_group_id, properties=:properties::jsonb, schedule=:schedule::jsonb, observation_group_id=:observation_group_id, modified=now(), hidden=:hidden, no_schedule=:no_schedule WHERE study_id=:study_id AND observation_id=:observation_id"; private static final String DELETE_ALL = "DELETE FROM observations"; private static final String SET_OBSERVATION_PROPERTIES_FOR_PARTICIPANT = "INSERT INTO participant_observation_properties(study_id,participant_id,observation_id,properties) VALUES (:study_id,:participant_id,:observation_id,:properties::jsonb) ON CONFLICT (study_id, participant_id, observation_id) DO UPDATE SET properties = EXCLUDED.properties"; private static final String GET_OBSERVATION_PROPERTIES_FOR_PARTICIPANT = "SELECT properties FROM participant_observation_properties WHERE study_id = ? AND participant_id = ? AND observation_id = ?"; @@ -53,8 +59,25 @@ public ObservationRepository(JdbcTemplate template) { public Observation insert(Observation observation) { try { return namedTemplate.queryForObject(INSERT_NEW_OBSERVATION, toParams(observation), getObservationRowMapper()); - } catch (DataIntegrityViolationException | JsonProcessingException e) { - throw new BadRequestException("Study group " + observation.getStudyGroupId() + " does not exist on study " + observation.getStudyId()); + } catch (DataIntegrityViolationException e) { + String message; + if(observation.getStudyGroupId() != null && observation.getObservationGroupId() != null) { + message = String.format("Study group %s and/or observation group %s do not exist on study %s", + observation.getStudyGroupId(), observation.getObservationGroupId(), observation.getStudyId()); + } else if(observation.getStudyGroupId() != null) { + message = String.format("Study group %s does not exist on study %s", + observation.getStudyGroupId(), observation.getStudyId()); + } else if (observation.getObservationGroupId() != null) { + message = String.format("Observation group %s does not exist on study %s", + observation.getObservationGroupId(), observation.getStudyId()); + } else { + message = String.format("Encountered %s while inserting observation", e.getClass().getSimpleName()); + LOG.warn("Unable to insert {}", observation, e); + } + throw new BadRequestException(message); + } catch (JsonProcessingException e){ + LOG.warn("Unable to insert {}", observation, e); + throw new BadRequestException("Unable to insert observation (" + e.getClass().getSimpleName() + ": " + e.getMessage() +")"); } } @@ -97,11 +120,29 @@ public List listObservations(Long studyId) { ); } + /** + * Lists all Observation based for the parsed study, study group and as per default no assigned observation group + * @param studyId the study + * @param studyGroupId the study group or NULL of none + * @return the Observations + */ public List listObservationsForGroup(Long studyId, Integer studyGroupId) { + return listObservationsForGroup(studyId, studyGroupId, List.of()); + } + + /** + * Lists all Observation based for the parsed study, study group and observation groups + * @param studyId the study + * @param studyGroupId the study group or NULL of none + * @param observationGroupIds the observation groups or an empty collection if none + * @return the Observations + */ + public List listObservationsForGroup(Long studyId, Integer studyGroupId, Collection observationGroupIds) { return namedTemplate.query( LIST_OBSERVATIONS_FOR_GROUP, new MapSqlParameterSource("study_id", studyId) - .addValue("study_group_id", studyGroupId), + .addValue("study_group_id", studyGroupId) + .addValue("observation_group_ids", observationGroupIds == null ? new Integer[0] : observationGroupIds.toArray(new Integer[0])), getObservationRowMapper() ); } @@ -158,7 +199,8 @@ private static MapSqlParameterSource toParams(Observation observation) throws Js .addValue("properties", MapperUtils.writeValueAsString(observation.getProperties())) .addValue("schedule", MapperUtils.writeValueAsString(observation.getSchedule())) .addValue("hidden", observation.getHidden()) - .addValue("no_schedule", observation.getNoSchedule()); + .addValue("no_schedule", observation.getNoSchedule()) + .addValue("observation_group_id", observation.getObservationGroupId()); } private static RowMapper getParticipantObservationPropertiesRowMapper() { @@ -179,6 +221,7 @@ private static RowMapper getObservationRowMapper() { .setCreated(RepositoryUtils.readInstant(rs, "created")) .setModified(RepositoryUtils.readInstant(rs, "modified")) .setHidden(rs.getBoolean("hidden")) - .setNoSchedule(rs.getBoolean("no_schedule")); + .setNoSchedule(rs.getBoolean("no_schedule")) + .setObservationGroupId(RepositoryUtils.getValidNullableIntegerValue(rs,"observation_group_id")); } } diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/ParticipantRepository.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/ParticipantRepository.java index de03f205..96e9bfe7 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/ParticipantRepository.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/ParticipantRepository.java @@ -13,6 +13,8 @@ import io.redlink.more.studymanager.model.Participant; import java.util.List; import java.util.Optional; +import java.util.Set; + import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; @@ -37,8 +39,24 @@ INSERT INTO registration_tokens(study_id, participant_id, token) VALUES (:study_id, :participant_id, :token) ON CONFLICT (study_id, participant_id) DO UPDATE SET token = excluded.token """; - private static final String GET_PARTICIPANT_BY_IDS = "SELECT p.participant_id, p.study_id, p.alias, p.study_group_id, r.token as token, p.status, p.created, p.modified, p.start FROM participants p LEFT JOIN registration_tokens r ON p.study_id = r.study_id AND p.participant_id = r.participant_id WHERE p.study_id = ? AND p.participant_id = ?"; - private static final String LIST_PARTICIPANTS_BY_STUDY = "SELECT p.participant_id, p.study_id, p.alias, p.study_group_id, r.token as token, p.status, p.created, p.modified, p.start FROM participants p LEFT JOIN registration_tokens r ON p.study_id = r.study_id AND p.participant_id = r.participant_id WHERE p.study_id = ?"; + private static final String GET_PARTICIPANT_BY_IDS = + "SELECT " + + " p.participant_id, p.study_id, p.alias, p.study_group_id, r.token as token, p.status, p.created, " + + " p.modified, p.start, ARRAY_AGG(pog.observation_group_id) FILTER (WHERE pog.observation_group_id IS NOT NULL) AS observation_group_ids " + + "FROM participants p " + + " LEFT JOIN registration_tokens r ON p.study_id = r.study_id AND p.participant_id = r.participant_id " + + " LEFT JOIN participant_observation_groups pog ON p.study_id = pog.study_id AND p.participant_id = pog.participant_id " + + "WHERE p.study_id = ? AND p.participant_id = ? " + + "GROUP BY p.study_id, p.participant_id, r.token"; + private static final String LIST_PARTICIPANTS_BY_STUDY = + "SELECT " + + " p.participant_id, p.study_id, p.alias, p.study_group_id, r.token as token, p.status, p.created, " + + " p.modified, p.start, ARRAY_AGG(pog.observation_group_id) FILTER (WHERE pog.observation_group_id IS NOT NULL) AS observation_group_ids " + + "FROM participants p " + + " LEFT JOIN registration_tokens r ON p.study_id = r.study_id AND p.participant_id = r.participant_id " + + " LEFT JOIN participant_observation_groups pog ON p.study_id = pog.study_id AND p.participant_id = pog.participant_id " + + "WHERE p.study_id = ? " + + "GROUP BY p.study_id, p.participant_id, r.token"; private static final String DELETE_PARTICIPANT = "DELETE FROM participants " + "WHERE study_id=? AND participant_id=?"; @@ -49,7 +67,9 @@ ON CONFLICT (study_id, participant_id) DO UPDATE SET token = excluded.token private static final String SET_STATUS = "UPDATE participants p SET status = :status::participant_status, modified = now() " + "WHERE study_id = :study_id AND participant_id = :participant_id " + - "RETURNING *, (SELECT token FROM registration_tokens t WHERE t.study_id = p.study_id AND t.participant_id = p.participant_id ) as token"; + "RETURNING *, " + + " (SELECT token FROM registration_tokens t WHERE t.study_id = p.study_id AND t.participant_id = p.participant_id ) as token, " + + " (SELECT ARRAY_AGG(observation_group_id) FROM participant_observation_groups pog WHERE pog.study_id = p.study_id AND pog.participant_id = p.participant_id ) as observation_group_ids"; private static final String SET_STATUS_IF = "UPDATE participants p SET status= :new_status::participant_status, modified = now() " + "WHERE study_id = :study_id AND participant_id = :participant_id " + @@ -57,16 +77,28 @@ ON CONFLICT (study_id, participant_id) DO UPDATE SET token = excluded.token "RETURNING *, (SELECT token FROM registration_tokens t WHERE t.study_id = p.study_id AND t.participant_id = p.participant_id ) as token"; private static final String LIST_PARTICIPANTS_FOR_CLOSING = - "SELECT DISTINCT p.*, 't' as token " + + "SELECT DISTINCT p.*, 't' as token, ARRAY_AGG(pog.observation_group_id) AS observation_group_ids " + "FROM studies s " + " JOIN participants p ON s.study_id = p.study_id " + " LEFT JOIN study_groups sg ON p.study_group_id = sg.study_group_id AND p.study_id = sg.study_id " + + " LEFT JOIN participant_observation_groups pog ON p.study_id = pog.study_id AND p.participant_id = pog.participant_id " + "WHERE s.status = 'active' " + " AND p.status = 'active' " + " AND p.start IS NOT NULL " + " AND COALESCE(sg.duration, s.duration) IS NOT NULL " + " AND (p.start + ((COALESCE(sg.duration, s.duration)->>'value')::int || ' ' || (COALESCE(sg.duration, s.duration)->>'unit'))::interval) < NOW()"; + /* + * SQL Statements for managing participant_observation_groups mapping for participants + */ + private static final String DELETE_PARTICIPANT_OBSERVATION_GROUP_IDS = + "DELETE FROM participant_observation_groups " + + "WHERE study_id = :study_id AND participant_id = :participant_id;"; + + private static final String SET_PARTICIPANT_OBSERVATION_GROUP_IDS = + "INSERT INTO participant_observation_groups (study_id, participant_id, observation_group_id) " + + "SELECT :study_id, :participant_id, unnest(:observation_group_ids::int[]);"; + private static final String DELETE_ALL = "DELETE FROM participants"; private final JdbcTemplate template; private final NamedParameterJdbcTemplate namedTemplate; @@ -84,7 +116,9 @@ public Participant insert(Participant participant) { } catch (DataIntegrityViolationException e) { throw new BadRequestException("Study " + participant.getStudyId() + " does not exist"); } - return getByIds(participant.getStudyId(), keyHolder.getKey().intValue()); + Integer participantId = keyHolder.getKey().intValue(); + setParticipantObservationGroupIds(participant.getStudyId(), participantId, participant.getObservationGroupIds()); + return getByIds(participant.getStudyId(), participantId); } public Participant getByIds(long studyId, int participantId) { @@ -108,8 +142,15 @@ public void deleteParticipant(Long studyId, Integer participantId) { template.update(DELETE_PARTICIPANT, studyId, participantId); } + /** + * Updates the participant and the {@link Participant#getObservationGroupIds()} + * @param participant + * @return the updated participant as stored in the database + */ + @Transactional public Participant update(Participant participant) { namedTemplate.update(UPDATE_PARTICIPANT, toParams(participant).addValue("participant_id", participant.getParticipantId())); + setParticipantObservationGroupIds(participant.getStudyId(), participant.getParticipantId(), participant.getObservationGroupIds()); return getByIds(participant.getStudyId(), participant.getParticipantId()); } @@ -189,6 +230,17 @@ private static RowMapper getParticipantRowMapper() { .setModified(RepositoryUtils.readInstant(rs, "modified")) .setStatus(RepositoryUtils.readParticipantStatus(rs, "status")) .setStart(RepositoryUtils.readInstant(rs, "start")) - .setRegistrationToken(rs.getString("token")); + .setRegistrationToken(rs.getString("token")) + .setObservationGroupIds(RepositoryUtils.readSet(rs, "observation_group_ids", Integer.class)); } + + private void setParticipantObservationGroupIds(Long studyId, Integer participantId, Set observationGroupIds) { + final var params = toParams(studyId, participantId); + namedTemplate.update(DELETE_PARTICIPANT_OBSERVATION_GROUP_IDS, params); + if(observationGroupIds != null && !observationGroupIds.isEmpty()) { + params.addValue("observation_group_ids", observationGroupIds.toArray(new Integer[0])); + namedTemplate.update(SET_PARTICIPANT_OBSERVATION_GROUP_IDS, params); + } + } + } diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/RepositoryUtils.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/RepositoryUtils.java index bf895055..41875673 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/RepositoryUtils.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/RepositoryUtils.java @@ -9,13 +9,18 @@ package io.redlink.more.studymanager.repository; import io.redlink.more.studymanager.model.Participant; + +import java.sql.Array; import java.sql.ResultSet; import java.sql.SQLException; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; -import java.util.Calendar; -import java.util.TimeZone; +import java.util.*; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,7 +31,8 @@ public final class RepositoryUtils { private static final Logger LOG = LoggerFactory.getLogger(RepositoryUtils.class); public static final Calendar tzUTC = Calendar.getInstance(TimeZone.getTimeZone(ZoneOffset.UTC)); - private RepositoryUtils() {} + private RepositoryUtils() { + } public static Instant readInstant(ResultSet rs, String columnLabel) throws SQLException { var timestamp = rs.getTimestamp(columnLabel); @@ -47,9 +53,9 @@ public static Instant readInstantUTC(ResultSet rs, String columnLabel) throws SQ return timestamp.toInstant(); } - public static LocalDate readLocalDate(ResultSet rs, String columnLabel) throws SQLException{ + public static LocalDate readLocalDate(ResultSet rs, String columnLabel) throws SQLException { var date = rs.getDate(columnLabel); - if(date == null) { + if (date == null) { return null; } return date.toLocalDate(); @@ -98,4 +104,28 @@ public static RowMapper intReader(String columnLabel) { return anInt; }; } + + public static void consumeArray(ResultSet rs, String columnLabel, Class type, Consumer collector) throws SQLException { + Array sqlArray = rs.getArray(columnLabel); + if (sqlArray != null) { + Stream.of((Object[]) sqlArray.getArray()) + .filter(Objects::nonNull) //instead of an empty Array SQL adds a NULL element at idx:0 ... + .filter(e -> type.isAssignableFrom(e.getClass())) + .map(type::cast) + .forEach(collector::accept); + } + } + + public static Set readSet(ResultSet rs, String columnLabel, Class type) throws SQLException { + Set set = new HashSet<>(); + consumeArray(rs, columnLabel, type, set::add); + return set; + } + + public static List readList(ResultSet rs, String columnLabel, Class type) throws SQLException { + List list = new ArrayList<>(); + consumeArray(rs, columnLabel, type, list::add); + return list; + } + } diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/ObservationGroupService.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/ObservationGroupService.java new file mode 100644 index 00000000..0d327906 --- /dev/null +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/ObservationGroupService.java @@ -0,0 +1,58 @@ +/* + * 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 + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Elastic License 2.0. + */ +package io.redlink.more.studymanager.service; + +import io.redlink.more.studymanager.exception.NotFoundException; +import io.redlink.more.studymanager.model.ObservationGroup; +import io.redlink.more.studymanager.model.Study; +import io.redlink.more.studymanager.repository.ObservationGroupRepository; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class ObservationGroupService { + + StudyStateService studyStateService; + private final ObservationGroupRepository repository; + + public ObservationGroupService(StudyStateService studyStateService, ObservationGroupRepository repository) { + this.studyStateService = studyStateService; + this.repository = repository; + } + + public ObservationGroup createObservationGroup(ObservationGroup observationGroup) { + studyStateService.assertStudyNotInState(observationGroup.getStudyId(), Study.Status.CLOSED); + return this.repository.insert(observationGroup); + } + + public ObservationGroup importObservationGroup(Long studyId, ObservationGroup observationGroupGroup) { + return this.repository.doImport(studyId, observationGroupGroup); + } + + public List listObservationGroups(long studyId) { + return this.repository.listObservationGroupsOrderedByObservationGroupIdAsc(studyId); + } + + public ObservationGroup getObservationGroup(long studyId, int observationGroupId) { + return Optional.ofNullable(repository.getByIds(studyId, observationGroupId)) + .orElseThrow(() -> NotFoundException.ObservationGroup(studyId, observationGroupId)); + } + + public ObservationGroup updateObservationGroup(ObservationGroup observationGroup) { + studyStateService.assertStudyNotInState(observationGroup.getStudyId(), Study.Status.CLOSED); + return this.repository.update(observationGroup); + } + + public void deleteObservationGroup(long studyId, int observationGroupId) { + studyStateService.assertStudyNotInState(studyId, Study.Status.CLOSED); + this.repository.deleteById(studyId, observationGroupId); + } +} diff --git a/studymanager-services/src/main/resources/db/migration/V1_19_0__add_observation_groups.sql b/studymanager-services/src/main/resources/db/migration/V1_19_0__add_observation_groups.sql new file mode 100644 index 00000000..c84eb499 --- /dev/null +++ b/studymanager-services/src/main/resources/db/migration/V1_19_0__add_observation_groups.sql @@ -0,0 +1,51 @@ +-- Observation Groups +-- +-- An observation_group allows to group observations and interventions the belog together. +-- Study participants can be assigned to [0..n] observation groups. +-- The intended use cases for observation_groups are: +-- 1. Define an observation group for a specific indication. Define related observations and intervention +-- within this observation group. Assign participants based on their indications to the relevant observation groups +-- 2. Define an observation group to group observations and interventions that depend on a physical device. +-- Assign all participants with this device to that observation group. + +CREATE TABLE observation_groups ( + study_id BIGINT NOT NULL, + observation_group_id INT NOT NULL, + title VARCHAR, + purpose TEXT, + created TIMESTAMP NOT NULL DEFAULT now(), + modified TIMESTAMP NOT NULL DEFAULT now(), + + PRIMARY KEY (study_id, observation_group_id), + FOREIGN KEY (study_id) REFERENCES studies(study_id) ON DELETE CASCADE +); + +CREATE INDEX observation_groups_study_id ON observation_groups(study_id); + +-- Study observations can be assigned [0..1] to an observation_groups +ALTER TABLE observations + ADD COLUMN observation_group_id INT, + + ADD FOREIGN KEY (study_id, observation_group_id) REFERENCES observation_groups(study_id, observation_group_id) ON DELETE SET NULL (observation_group_id); + +CREATE INDEX observations_observation_group_id ON observations(study_id, observation_group_id); + +-- Study interventions can be assigned [0..1] to an observation_groups +ALTER TABLE interventions + ADD COLUMN observation_group_id INT, + + ADD FOREIGN KEY (study_id, observation_group_id) REFERENCES observation_groups(study_id, observation_group_id) ON DELETE SET NULL (observation_group_id); + +CREATE INDEX interventions_observation_group_id ON interventions(study_id, observation_group_id); + +-- Study participants can be in [0..n] observation_groups +CREATE TABLE participant_observation_groups ( + study_id BIGINT NOT NULL, + participant_id INT NOT NULL, + observation_group_id INT NOT NULL, + + PRIMARY KEY (study_id, participant_id, observation_group_id), + FOREIGN KEY (study_id) REFERENCES studies(study_id) ON DELETE CASCADE, + FOREIGN KEY (study_id, observation_group_id) REFERENCES observation_groups(study_id, observation_group_id) ON DELETE CASCADE, + FOREIGN KEY (study_id, participant_id) REFERENCES participants(study_id, participant_id) ON DELETE CASCADE +); diff --git a/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/InterventionRepositoryTest.java b/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/InterventionRepositoryTest.java index 6e3e9b8a..4b1ff74b 100644 --- a/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/InterventionRepositoryTest.java +++ b/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/InterventionRepositoryTest.java @@ -31,6 +31,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Map; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -38,8 +39,8 @@ @Testcontainers @EnableAutoConfiguration @ContextConfiguration(classes = { - InterventionRepository.class, StudyRepository.class, StudyGroupRepository.class, - JPAConfiguration.class + InterventionRepository.class, StudyRepository.class, StudyGroupRepository.class, ObservationGroupRepository.class, + JPAConfiguration.class }) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) @ActiveProfiles("test-containers-flyway") @@ -54,6 +55,9 @@ class InterventionRepositoryTest { @Autowired private StudyGroupRepository studyGroupRepository; + @Autowired + private ObservationGroupRepository observationGroupRepository; + @BeforeEach void deleteAll() { interventionRepository.clear(); @@ -67,6 +71,9 @@ void testInsertListUpdateDelete() { Instant startTime = Instant.now(); Instant endTime = Instant.now().plus(2, ChronoUnit.HOURS); + Integer observationGroupId1 = observationGroupRepository.insert(new ObservationGroup().setStudyId(studyId).setTitle("Observation Group 1").setPurpose("test")).getObservationGroupId(); + Integer observationGroupId2 = observationGroupRepository.insert(new ObservationGroup().setStudyId(studyId).setTitle("Observation Group 2").setPurpose("test")).getObservationGroupId(); + Intervention intervention = new Intervention() .setStudyId(studyId) .setTitle("some title") @@ -75,14 +82,10 @@ void testInsertListUpdateDelete() { .setSchedule(new Event() .setDateStart(startTime) .setDateEnd(endTime) - .setRRule(new RecurrenceRule().setFreq("DAILY").setCount(7)));; - - Intervention intervention2 = new Intervention() - .setStudyId(studyId) - .setTitle("some other title") - .setStudyGroupId(studyGroupId) - .setSchedule(new Event().setDateEnd(Instant.now()).setDateEnd(Instant.now().plusSeconds(60))); + .setRRule(new RecurrenceRule().setFreq("DAILY").setCount(7))) + .setObservationGroupId(observationGroupId1); + //insert Intervention interventionResponse = interventionRepository.insert(intervention); assertThat(interventionResponse.getInterventionId()).isNotNull(); @@ -90,26 +93,95 @@ void testInsertListUpdateDelete() { assertThat(((Event)interventionResponse.getSchedule()).getDateStart()).isEqualTo(startTime); assertThat(MapperUtils.writeValueAsString(interventionResponse.getSchedule())) .isEqualTo(MapperUtils.writeValueAsString(intervention.getSchedule())); + assertThat(interventionResponse.getObservationGroupId()).isEqualTo(observationGroupId1); - interventionResponse = interventionRepository.updateIntervention(new Intervention() - .setStudyId(studyId) - .setInterventionId(interventionResponse.getInterventionId()) + //update + interventionResponse = interventionRepository.updateIntervention(interventionResponse .setTitle("some new title") - .setStudyGroupId(studyGroupId) - .setSchedule(new Event().setDateEnd(Instant.now()).setDateEnd(Instant.now().plusSeconds(60)))); + .setObservationGroupId(null)); assertThat(interventionResponse.getTitle()).isEqualTo("some new title"); + assertThat(interventionResponse.getObservationGroupId()).isNull(); - int intervention2Id = interventionRepository.insert(intervention2).getInterventionId(); - - assertThat(interventionRepository.listInterventions(studyId).size()).isEqualTo(2); + Intervention intervention2 = interventionRepository.insert(new Intervention() + .setStudyId(studyId) + .setTitle("Intervention 2") + .setSchedule(new Event().setDateEnd(Instant.now()).setDateEnd(Instant.now().plusSeconds(60))) + .setStudyGroupId(null) + .setObservationGroupId(null)); + Intervention intervention3a = interventionRepository.insert(new Intervention() + .setStudyId(studyId) + .setTitle("Intervaion 3a") + .setSchedule(new Event().setDateEnd(Instant.now()).setDateEnd(Instant.now().plusSeconds(60))) + .setStudyGroupId(null) + .setObservationGroupId(observationGroupId1)); + Intervention intervention3b = interventionRepository.insert(new Intervention() + .setStudyId(studyId) + .setTitle("Intervaion 3b") + .setSchedule(new Event().setDateEnd(Instant.now()).setDateEnd(Instant.now().plusSeconds(60))) + .setStudyGroupId(null) + .setObservationGroupId(observationGroupId2)); + Intervention intervention4a = interventionRepository.insert(new Intervention() + .setStudyId(studyId) + .setTitle("Intervaion 4a") + .setSchedule(new Event().setDateEnd(Instant.now()).setDateEnd(Instant.now().plusSeconds(60))) + .setStudyGroupId(studyGroupId) + .setObservationGroupId(observationGroupId1)); + Intervention intervention4b = interventionRepository.insert(new Intervention() + .setStudyId(studyId) + .setTitle("Intervaion 4b") + .setSchedule(new Event().setDateEnd(Instant.now()).setDateEnd(Instant.now().plusSeconds(60))) + .setStudyGroupId(studyGroupId) + .setObservationGroupId(observationGroupId2)); + + assertThat(interventionRepository.listInterventions(studyId).size()).isEqualTo(6); + + //list interventions with no groups + assertThat(interventionRepository.listInterventionsForGroup(studyId,null, Set.of())) + .extracting(Intervention::getInterventionId) + .containsExactly(intervention2.getInterventionId()); //intervantion 2 is not part of any group + //same for none existing group IDs + assertThat(interventionRepository.listInterventionsForGroup(studyId,-1, Set.of(-1))) + .extracting(Intervention::getInterventionId) + .containsExactly(intervention2.getInterventionId()); //intervantion 2 is not part of any group + //list interventions in observation group 1 with no studyGroup (or no studyGroup) + assertThat(interventionRepository.listInterventionsForGroup(studyId,null, Set.of(observationGroupId1))) + .extracting(Intervention::getInterventionId) + .containsExactly(intervention2.getInterventionId(), intervention3a.getInterventionId()); + //same for none existing group + assertThat(interventionRepository.listInterventionsForGroup(studyId,-1, Set.of(observationGroupId1))) + .extracting(Intervention::getInterventionId) + .containsExactly(intervention2.getInterventionId(), intervention3a.getInterventionId()); + //list interventions in study group 1 and observation group 1 with no studyGroup (or no studyGroup) + assertThat(interventionRepository.listInterventionsForGroup(studyId, studyGroupId, Set.of(observationGroupId1))) + .extracting(Intervention::getInterventionId) + .containsExactly( + interventionResponse.getInterventionId(), intervention2.getInterventionId(), + intervention3a.getInterventionId(), intervention4a.getInterventionId()); + + //list interventions in study group 1 and observation group 1 or observation group 2with no studyGroup (or no studyGroup) + assertThat(interventionRepository.listInterventionsForGroup(studyId, studyGroupId, Set.of(observationGroupId1, observationGroupId2))) + .extracting(Intervention::getInterventionId) + .containsExactly( + interventionResponse.getInterventionId(), intervention2.getInterventionId(), + intervention3a.getInterventionId(), intervention3b.getInterventionId(), + intervention4a.getInterventionId(), intervention4b.getInterventionId()); interventionRepository.deleteByIds(interventionResponse.getStudyId(), interventionResponse.getInterventionId()); - interventionResponse = interventionRepository.getByIds(intervention2.getStudyId(), intervention2Id); + interventionResponse = interventionRepository.getByIds(intervention2.getStudyId(), intervention2.getInterventionId()); + + assertThat(interventionResponse.getInterventionId()).isEqualTo(intervention2.getInterventionId()); + assertThat(interventionRepository.listInterventions(studyId).size()).isEqualTo(5); + + //assert delete Observation Group sets property of Observation to null + observationGroupRepository.deleteById(studyId, observationGroupId1); + assertThat((interventionRepository.getByIds(studyId, intervention3a.getInterventionId()).getObservationGroupId())) + .isNull(); + assertThat((interventionRepository.getByIds(studyId, intervention4a.getInterventionId()).getObservationGroupId())) + .isNull(); + - assertThat(interventionResponse.getInterventionId()).isEqualTo(intervention2Id); - assertThat(interventionRepository.listInterventions(studyId).size()).isEqualTo(1); } @Test diff --git a/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/ObservationGroupRepositoryTest.java b/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/ObservationGroupRepositoryTest.java new file mode 100644 index 00000000..8c61f23c --- /dev/null +++ b/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/ObservationGroupRepositoryTest.java @@ -0,0 +1,125 @@ +/* + * 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 + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Elastic License 2.0. + */ +package io.redlink.more.studymanager.repository; + +import io.redlink.more.studymanager.configuration.JPAConfiguration; +import io.redlink.more.studymanager.core.properties.ObservationProperties; +import io.redlink.more.studymanager.model.*; +import io.redlink.more.studymanager.model.scheduler.*; +import io.redlink.more.studymanager.utils.MapperUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.Instant; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Testcontainers +@EnableAutoConfiguration +@ContextConfiguration(classes = { + ObservationGroupRepository.class, StudyRepository.class, + JPAConfiguration.class +}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@ActiveProfiles("test-containers-flyway") +class ObservationGroupRepositoryTest { + + @Autowired + private ObservationGroupRepository observationGroupRepository; + + @Autowired + private StudyRepository studyRepository; + + @BeforeEach + void deleteAll() { + observationGroupRepository.clear(); + } + + @Test + @DisplayName("ObservationGroups are inserted, updated, listed and deleted from database") + public void testInsertListUpdateDelete() throws InterruptedException { + + Long studyId = studyRepository.insert(new Study().setContact(new Contact().setPerson("test").setEmail("test"))).getStudyId(); + + //INSERT + ObservationGroup observationGroup = new ObservationGroup() + .setStudyId(studyId) + .setTitle("Test Observation Group") + .setPurpose("Test Purpose"); + + ObservationGroup observationGroupResponse = observationGroupRepository.insert(observationGroup); + + assertThat(observationGroupResponse.getObservationGroupId()).isNotNull(); + assertThat(observationGroupResponse.getStudyId()).isEqualTo(observationGroup.getStudyId()); + assertThat(observationGroupResponse.getTitle()).isEqualTo(observationGroup.getTitle()); + assertThat(observationGroupResponse.getPurpose()).isEqualTo(observationGroup.getPurpose()); + assertThat(observationGroupResponse.getCreated()).isNotNull(); + assertThat(observationGroupResponse.getModified()).isNotNull(); + assertThat(observationGroupResponse.getCreated()).isEqualTo(observationGroupResponse.getModified()); + + //UPDATE + observationGroupResponse + .setTitle("Updated Observation Group") + .setPurpose("Updated Purpose"); + + TimeUnit.MICROSECONDS.sleep(1); //to ensure a different modified time + + ObservationGroup compareObservationGroupResponse = observationGroupRepository.update(observationGroupResponse); + + assertThat(compareObservationGroupResponse.getTitle()).isEqualTo(observationGroupResponse.getTitle()); + assertThat(compareObservationGroupResponse.getPurpose()).isEqualTo(observationGroupResponse.getPurpose()); + assertThat(compareObservationGroupResponse.getStudyId()).isEqualTo(observationGroupResponse.getStudyId()); + assertThat(compareObservationGroupResponse.getObservationGroupId()).isEqualTo(observationGroupResponse.getObservationGroupId()); + assertThat(compareObservationGroupResponse.getCreated()).isEqualTo(observationGroupResponse.getCreated()); + assertThat(compareObservationGroupResponse.getModified()).isAfter(observationGroupResponse.getModified()); + + ObservationGroup observationGroup2Response = observationGroupRepository.insert(new ObservationGroup() + .setStudyId(studyId) + .setTitle("Test Observation Group 2") + .setPurpose("Test Purpose 2")); + + Long studyId2 = studyRepository.insert(new Study().setContact(new Contact().setPerson("test3").setEmail("test3"))).getStudyId(); + + ObservationGroup observationGroupStudy2Response = observationGroupRepository.insert(new ObservationGroup() + .setStudyId(studyId2) + .setTitle("Test Observation Group Study 2") + .setPurpose("Test Purpose 3")); + + List study1ObserationGroups = observationGroupRepository.listObservationGroupsOrderedByObservationGroupIdAsc(studyId); + assertThat(study1ObserationGroups.size()).isEqualTo(2); + assertThat(study1ObserationGroups.get(0).getStudyId()).isEqualTo(studyId); + assertThat(study1ObserationGroups.get(0).getObservationGroupId()).isEqualTo(observationGroupResponse.getObservationGroupId()); + assertThat(study1ObserationGroups.get(1).getStudyId()).isEqualTo(studyId); + assertThat(study1ObserationGroups.get(1).getObservationGroupId()).isEqualTo(observationGroup2Response.getObservationGroupId()); + + List study2ObserationGroups = observationGroupRepository.listObservationGroupsOrderedByObservationGroupIdAsc(studyId2); + assertThat(study2ObserationGroups.size()).isEqualTo(1); + assertThat(study2ObserationGroups.get(0).getStudyId()).isEqualTo(studyId2); + assertThat(study2ObserationGroups.get(0).getObservationGroupId()).isEqualTo(observationGroupStudy2Response.getObservationGroupId()); + + // Delete the group specific observations + observationGroupRepository.deleteById(studyId, observationGroup2Response.getObservationGroupId()); + assertThat(observationGroupRepository.listObservationGroupsOrderedByObservationGroupIdAsc(studyId)) + .hasSize(1); + } +} diff --git a/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/ObservationRepositoryTest.java b/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/ObservationRepositoryTest.java index 931c6640..0fe64126 100644 --- a/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/ObservationRepositoryTest.java +++ b/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/ObservationRepositoryTest.java @@ -30,6 +30,7 @@ import java.time.LocalTime; import java.time.temporal.ChronoUnit; import java.util.Map; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -37,7 +38,7 @@ @Testcontainers @EnableAutoConfiguration @ContextConfiguration(classes = { - ObservationRepository.class, StudyRepository.class, ParticipantRepository.class, + ObservationRepository.class, StudyRepository.class, ParticipantRepository.class, ObservationGroupRepository.class, StudyGroupRepository.class, JPAConfiguration.class }) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) @@ -56,6 +57,9 @@ class ObservationRepositoryTest { @Autowired private StudyGroupRepository studyGroupRepository; + @Autowired + private ObservationGroupRepository observationGroupRepository; + @BeforeEach void deleteAll() { observationRepository.clear(); @@ -70,6 +74,9 @@ public void testInsertListUpdateDelete() { Instant startTime = Instant.now(); Instant endTime = Instant.now().plus(2, ChronoUnit.HOURS); + Integer observationGroupId1 = observationGroupRepository.insert(new ObservationGroup().setStudyId(studyId).setTitle("Observation Group 1").setPurpose("test")).getObservationGroupId(); + Integer observationGroupId2 = observationGroupRepository.insert(new ObservationGroup().setStudyId(studyId).setTitle("Observation Group 2").setPurpose("test")).getObservationGroupId(); + Observation observation = new Observation() .setStudyId(studyId) .setType(type) @@ -81,7 +88,8 @@ public void testInsertListUpdateDelete() { .setDateEnd(endTime) .setRRule(new RecurrenceRule().setFreq("DAILY").setCount(7))) .setHidden(true) - .setNoSchedule(false); + .setNoSchedule(false) + .setObservationGroupId(observationGroupId1); Observation observationResponse = observationRepository.insert(observation); @@ -90,12 +98,14 @@ public void testInsertListUpdateDelete() { assertThat(observationResponse.getProperties()).isEqualTo(observation.getProperties()); assertThat(MapperUtils.writeValueAsString(observationResponse.getSchedule())) .isEqualTo(MapperUtils.writeValueAsString(observation.getSchedule())); + assertThat(observationResponse.getObservationGroupId()).isEqualTo(observationGroupId1); Integer oldId = observationResponse.getObservationId(); observationResponse.setType("new type") .setTitle("some new title") - .setSchedule(new Event().setDateEnd(Instant.now()).setDateEnd(Instant.now().plusSeconds(60))); + .setSchedule(new Event().setDateEnd(Instant.now()).setDateEnd(Instant.now().plusSeconds(60))) + .setObservationGroupId(null); Observation compareObservationResponse = observationRepository.updateObservation(observationResponse); @@ -103,6 +113,7 @@ public void testInsertListUpdateDelete() { assertThat(compareObservationResponse.getType()).isEqualTo(type); assertThat(compareObservationResponse.getObservationId()).isEqualTo(oldId); assertThat(compareObservationResponse.getSchedule()).isNotEqualTo(observation.getSchedule()); + assertThat(observationResponse.getObservationGroupId()).isNull(); Observation observationResponse2 = observationRepository.insert(new Observation() .setStudyId(studyId) @@ -111,39 +122,89 @@ public void testInsertListUpdateDelete() { .setHidden(true) .setNoSchedule(true) ); + Observation observationResponse3a = observationRepository.insert(new Observation() + .setStudyId(studyId) + .setType("gps") + .setType("new Title - obs-group 1") + .setHidden(true) + .setNoSchedule(true) + .setObservationGroupId(observationGroupId1) + ); + Observation observationResponse3b = observationRepository.insert(new Observation() + .setStudyId(studyId) + .setType("gps") + .setType("new Title 2") + .setHidden(true) + .setNoSchedule(true) + .setObservationGroupId(observationGroupId2) + ); - assertThat((observationRepository.listObservations(studyId))) + assertThat(observationRepository.listObservations(studyId)) .as("List all Observations") - .hasSize(2); - assertThat((observationRepository.listObservationsForGroup(studyId, studyGroupId))) - .as("Include group-specific observations and globals") - .hasSize(2); - assertThat((observationRepository.listObservationsForGroup(studyId, -1))) + .hasSize(4); + + assertThat(observationRepository.listObservationsForGroup(studyId, studyGroupId)) + .as("Include group-specific observations and globals") //NOTE: and in no observation group + .hasSize(2) + .extracting(Observation::getObservationId) + .containsOnly(observationResponse.getObservationId(), observationResponse2.getObservationId()); + + assertThat(observationRepository.listObservationsForGroup(studyId, -1)) .as("Non-existing Group should only retrieve 'global' observations") - .hasSize(1); - assertThat((observationRepository.listObservationsForGroup(studyId, null))) + .hasSize(1) + .extracting(Observation::getObservationId) + .containsExactly(observationResponse2.getObservationId()); + + assertThat(observationRepository.listObservationsForGroup(studyId, null)) .as("-Group should only retrieve 'global' observations") .hasSize(1) .as("Check for the global observation") .extracting(Observation::getObservationId) - .contains(observationResponse2.getObservationId()); + .containsExactly(observationResponse2.getObservationId()); + + //list Observations for any study-group and no observation-group of observation-group 'observationGroupId1' -> + //this is true for obs2 (no group at all) and obs3 (in the correct observation group) + assertThat(observationRepository.listObservationsForGroup(studyId, null, Set.of(observationGroupId1))) + .as("Obseration-Group should only retrieve observation in that observation group") + .hasSize(2) + .extracting(Observation::getObservationId) + .containsExactly(observationResponse2.getObservationId(), observationResponse3a.getObservationId()); + + + //list Observations for study-group '-1' and no observation-group of observation-group 'observationGroupId1' -> + // This is true for all expect 'obs1' + assertThat(observationRepository.listObservationsForGroup(studyId, -1, Set.of(observationGroupId1, observationGroupId2))) + .as("Obseration-Group should only retrieve observation in that observation group") + .hasSize(3) + .extracting(Observation::getObservationId) + .containsExactly(observationResponse2.getObservationId(), observationResponse3a.getObservationId(), observationResponse3b.getObservationId()); + + //test relative events + observation.setSchedule(new RelativeEvent() + .setDtstart(new RelativeDate().setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)).setTime(LocalTime.parse("12:00:00"))) + .setDtend(new RelativeDate().setOffset(new Duration().setValue(2).setUnit(Duration.Unit.DAY)).setTime(LocalTime.parse("13:00:00")))); + Observation observationResponse4 = observationRepository.insert(observation); + assertThat(observationResponse4.getSchedule()) + .isInstanceOf(RelativeEvent.class); + + //assert delete Observation Group sets property of Observation to null + observationGroupRepository.deleteById(studyId, observationGroupId1); + assertThat((observationRepository.getById(studyId, observationResponse3a.getObservationId()).getObservationGroupId())) + .isNull(); + // Delete the group specific observations + observationRepository.deleteObservation(studyId, observationResponse4.getObservationId()); observationRepository.deleteObservation(studyId, observationResponse.getObservationId()); assertThat((observationRepository.listObservations(studyId))) - .hasSize(1); + .hasSize(3); assertThat((observationRepository.listObservationsForGroup(studyId, studyGroupId))) - .hasSize(1); + .hasSize(2) //now that we deleted observationGroupId1 -> observationResponse3a is also in no group + .extracting(Observation::getObservationId) + .containsExactly(observationResponse2.getObservationId(), observationResponse3a.getObservationId()); observationRepository.deleteObservation(studyId, observationResponse2.getObservationId()); assertThat((observationRepository.listObservations(studyId))) - .hasSize(0); - - observation.setSchedule(new RelativeEvent() - .setDtstart(new RelativeDate().setOffset(new Duration().setValue(1).setUnit(Duration.Unit.DAY)).setTime(LocalTime.parse("12:00:00"))) - .setDtend(new RelativeDate().setOffset(new Duration().setValue(2).setUnit(Duration.Unit.DAY)).setTime(LocalTime.parse("13:00:00")))); + .hasSize(2); - Observation observationResponse3 = observationRepository.insert(observation); - assertThat(observationResponse3.getSchedule()) - .isInstanceOf(RelativeEvent.class); } @Test diff --git a/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/ParticipantRepositoryTest.java b/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/ParticipantRepositoryTest.java index 0c846b8a..15285859 100644 --- a/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/ParticipantRepositoryTest.java +++ b/studymanager-services/src/test/java/io/redlink/more/studymanager/repository/ParticipantRepositoryTest.java @@ -9,10 +9,7 @@ package io.redlink.more.studymanager.repository; import io.redlink.more.studymanager.configuration.JPAConfiguration; -import io.redlink.more.studymanager.model.Contact; -import io.redlink.more.studymanager.model.Participant; -import io.redlink.more.studymanager.model.Study; -import io.redlink.more.studymanager.model.StudyGroup; +import io.redlink.more.studymanager.model.*; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -27,13 +24,16 @@ import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import java.util.Set; +import java.util.concurrent.TimeUnit; + import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest @Testcontainers @EnableAutoConfiguration @ContextConfiguration(classes = { - ParticipantRepository.class, StudyRepository.class, StudyGroupRepository.class, + ParticipantRepository.class, StudyRepository.class, StudyGroupRepository.class, ObservationGroupRepository.class, JPAConfiguration.class }) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) @@ -49,6 +49,9 @@ class ParticipantRepositoryTest { @Autowired private StudyGroupRepository studyGroupRepository; + @Autowired + private ObservationGroupRepository observationGroupRepository; + @BeforeEach void deleteAll() { participantRepository.clear(); @@ -56,38 +59,51 @@ void deleteAll() { @Test @DisplayName("Participant is inserted and returned") - void testInsert() { + void testInsert() throws InterruptedException { Long studyId = studyRepository.insert(new Study().setContact(new Contact().setPerson("test").setEmail("test"))).getStudyId(); Integer studyGroupId = studyGroupRepository.insert(new StudyGroup() .setStudyId(studyId)).getStudyGroupId(); + Integer observationGroupId1 = observationGroupRepository.insert(new ObservationGroup().setStudyId(studyId).setTitle("Observation Group 1").setPurpose("test")).getObservationGroupId(); + Integer observationGroupId2 = observationGroupRepository.insert(new ObservationGroup().setStudyId(studyId).setTitle("Observation Group 2").setPurpose("test")).getObservationGroupId(); + Integer observationGroupId3 = observationGroupRepository.insert(new ObservationGroup().setStudyId(studyId).setTitle("Observation Group 3").setPurpose("test")).getObservationGroupId(); + Participant participant = new Participant() .setAlias("participant x") .setStudyGroupId(studyGroupId) .setStudyId(studyId) - .setRegistrationToken("TEST123"); + .setRegistrationToken("TEST123") + .setObservationGroupIds(Set.of(observationGroupId1, observationGroupId2)); Participant participantResponse = participantRepository.insert(participant); assertThat(participantResponse.getAlias()).isEqualTo(participant.getAlias()); assertThat(participantResponse.getStatus()).isEqualTo(Participant.Status.NEW); assertThat(participantResponse.getParticipantId()).isNotNull(); + assertThat(participantResponse.getObservationGroupIds()).containsExactlyInAnyOrder(observationGroupId1, observationGroupId2); + + Participant update = participantResponse + .setAlias("new participant x") + .setObservationGroupIds(Set.of(observationGroupId1, observationGroupId3)); //replace observationGroup1 with observationGroup3 - Participant update = participantResponse.setAlias("new participant x"); + TimeUnit.MICROSECONDS.sleep(1); //to ensure a different modified time Participant updated = participantRepository.update(update); + assertThat(update.getStudyId()).isEqualTo(participantResponse.getStudyId()); + assertThat(update.getAlias()).isEqualTo(updated.getAlias()); + assertThat(update.getObservationGroupIds()).containsExactlyInAnyOrder(observationGroupId1, observationGroupId3); + assertThat(updated.getCreated()).isEqualTo(participantResponse.getCreated()); + assertThat(updated.getModified()).isAfter(participantResponse.getModified()); + Participant queried = participantRepository.getByIds(participantResponse.getStudyId(), participantResponse.getParticipantId()); assertThat(queried.getAlias()).isEqualTo(updated.getAlias()); assertThat(queried.getStudyId()).isEqualTo(updated.getStudyId()); assertThat(queried.getCreated()).isEqualTo(updated.getCreated()); assertThat(queried.getStatus()).isEqualTo(updated.getStatus()); + assertThat(queried.getObservationGroupIds()).containsExactlyInAnyOrder(observationGroupId1, observationGroupId3); - assertThat(update.getAlias()).isEqualTo(updated.getAlias()); - assertThat(participantResponse.getStudyId()).isEqualTo(updated.getStudyId()); - assertThat(participantResponse.getCreated()).isEqualTo(updated.getCreated()); - assertThat(participantResponse.getModified().toEpochMilli()).isLessThan(updated.getModified().toEpochMilli()); } @Test @@ -143,16 +159,25 @@ private Participant createParticipant(Long studyId) { void testSetState() { Long studyId = studyRepository.insert(new Study().setContact(new Contact().setPerson("test").setEmail("test"))).getStudyId(); - Participant participant = participantRepository.insert(new Participant().setStudyId(studyId).setRegistrationToken("TEST123")); + Integer observationGroup1 = observationGroupRepository.insert(new ObservationGroup().setStudyId(studyId).setTitle("Observation Group 1").setPurpose("Purpose 1")).getObservationGroupId(); + Integer observationGroup2 = observationGroupRepository.insert(new ObservationGroup().setStudyId(studyId).setTitle("Observation Group 2").setPurpose("Purpose 2")).getObservationGroupId(); + + Participant participant = participantRepository.insert(new Participant().setStudyId(studyId).setRegistrationToken("TEST123").setObservationGroupIds(Set.of(observationGroup1,observationGroup2))); assertThat(participant.getStatus()).isEqualTo(Participant.Status.NEW); participant = participantRepository.getByIds(studyId, participant.getParticipantId()); assertThat(participant.getStatus()).isEqualTo(Participant.Status.NEW); - participantRepository.setStatusByIds(studyId, participant.getParticipantId(), Participant.Status.ACTIVE); + participant = participantRepository.setStatusByIds(studyId, participant.getParticipantId(), Participant.Status.ACTIVE).get(); + assertThat(participant.getStatus()).isEqualTo(Participant.Status.ACTIVE); + //Assert that the SQL query for the status update correctly retrieves the observation groups for the participant + assertThat(participant.getObservationGroupIds()).containsExactlyInAnyOrder(observationGroup1, observationGroup2); + //make an additional retrieval just to be sure participant = participantRepository.getByIds(studyId, participant.getParticipantId()); assertThat(participant.getStatus()).isEqualTo(Participant.Status.ACTIVE); + //Assert that the SQL query for the status update correctly retrieves the observation groups for the participant + assertThat(participant.getObservationGroupIds()).containsExactlyInAnyOrder(observationGroup1, observationGroup2); } @Test diff --git a/studymanager/pom.xml b/studymanager/pom.xml index e64b5209..f0dda56f 100644 --- a/studymanager/pom.xml +++ b/studymanager/pom.xml @@ -107,17 +107,17 @@ org.testcontainers - junit-jupiter + testcontainers-junit-jupiter test org.testcontainers - postgresql + testcontainers-postgresql test org.testcontainers - elasticsearch + testcontainers-elasticsearch test diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ObservationGroupApiV1Controller.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ObservationGroupApiV1Controller.java new file mode 100644 index 00000000..8226bbd1 --- /dev/null +++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ObservationGroupApiV1Controller.java @@ -0,0 +1,98 @@ +/* + * 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 + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Elastic License 2.0. + */ +package io.redlink.more.studymanager.controller.studymanager; + +import io.redlink.more.studymanager.api.v1.model.ObservationGroupDTO; +import io.redlink.more.studymanager.api.v1.webservices.ObservationGroupsApi; +import io.redlink.more.studymanager.audit.Audited; +import io.redlink.more.studymanager.controller.RequiresStudyRole; +import io.redlink.more.studymanager.model.ObservationGroup; +import io.redlink.more.studymanager.model.StudyRole; +import io.redlink.more.studymanager.model.transformer.ObservationGroupTransformer; +import io.redlink.more.studymanager.service.ObservationGroupService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping(value = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE) +public class ObservationGroupApiV1Controller implements ObservationGroupsApi { + + private static final Logger LOGGER = LoggerFactory.getLogger(ObservationGroupApiV1Controller.class); + + private final ObservationGroupService service; + + + public ObservationGroupApiV1Controller(ObservationGroupService service) { + this.service = service; + } + + @Override + @RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR}) + @Audited + public ResponseEntity createObservationGroup(Long studyId, ObservationGroupDTO observationGroupDTO) { + observationGroupDTO.setStudyId(studyId); + ObservationGroup observationGroup = service.createObservationGroup( + ObservationGroupTransformer.fromObservationGroupDTO_V1(observationGroupDTO) + ); + LOGGER.debug("ObservationGroup created: {}", observationGroup); + return ResponseEntity.status(HttpStatus.CREATED).body( + ObservationGroupTransformer.toObservationGroupDTO_V1(observationGroup) + ); + } + + @Override + @RequiresStudyRole + @Audited + public ResponseEntity> listObservationGroups(Long studyId) { + return ResponseEntity.ok( + service.listObservationGroups(studyId).stream() + .map(ObservationGroupTransformer::toObservationGroupDTO_V1) + .toList() + ); + } + + @Override + @RequiresStudyRole + @Audited + public ResponseEntity getObservationGroup(Long studyId, Integer observationGroupId) { + return ResponseEntity.ok( + ObservationGroupTransformer.toObservationGroupDTO_V1(service.getObservationGroup(studyId, observationGroupId)) + ); + } + + @Override + @RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR}) + @Audited + public ResponseEntity updateObservationGroup(Long studyId, Integer observationGroupId, ObservationGroupDTO observationGroupDTO) { + observationGroupDTO.setStudyId(studyId); + observationGroupDTO.setObservationGroupId(observationGroupId); + return ResponseEntity.ok( + ObservationGroupTransformer.toObservationGroupDTO_V1( + service.updateObservationGroup( + ObservationGroupTransformer.fromObservationGroupDTO_V1(observationGroupDTO) + ) + ) + ); + } + + @Override + @RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR}) + @Audited + public ResponseEntity deleteObservationGroup(Long studyId, Integer observationGroupId) { + service.deleteObservationGroup(studyId, observationGroupId); + return ResponseEntity.noContent().build(); + } +} diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/InterventionTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/InterventionTransformer.java index 7595dfb7..a5db41dc 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/InterventionTransformer.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/InterventionTransformer.java @@ -24,7 +24,8 @@ public static Intervention fromInterventionDTO_V1(InterventionDTO dto) { .setTitle(dto.getTitle()) .setPurpose(dto.getPurpose()) .setStudyGroupId(dto.getStudyGroupId()) - .setSchedule(EventTransformer.fromObservationScheduleDTO_V1(dto.getSchedule())); + .setSchedule(EventTransformer.fromObservationScheduleDTO_V1(dto.getSchedule())) + .setObservationGroupId(dto.getObservationGroupId()); } public static InterventionDTO toInterventionDTO_V1(Intervention intervention) { @@ -37,6 +38,7 @@ public static InterventionDTO toInterventionDTO_V1(Intervention intervention) { .purpose(intervention.getPurpose()) .studyGroupId(intervention.getStudyGroupId()) .schedule(EventTransformer.toObservationScheduleDTO_V1(intervention.getSchedule())) + .observationGroupId(intervention.getObservationGroupId()) .created(instant1) .modified(instant); } diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ObservationGroupTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ObservationGroupTransformer.java new file mode 100644 index 00000000..1a79a411 --- /dev/null +++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ObservationGroupTransformer.java @@ -0,0 +1,43 @@ +/* + * 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 + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Elastic License 2.0. + */ +package io.redlink.more.studymanager.model.transformer; + +import io.redlink.more.studymanager.api.v1.model.ObservationGroupDTO; +import io.redlink.more.studymanager.model.ObservationGroup; + +import java.time.Instant; + +public final class ObservationGroupTransformer { + + private ObservationGroupTransformer() { + } + + public static ObservationGroup fromObservationGroupDTO_V1(ObservationGroupDTO ObservationGroupDTO) { + return new ObservationGroup() + .setStudyId(ObservationGroupDTO.getStudyId()) + .setObservationGroupId(ObservationGroupDTO.getObservationGroupId()) + .setTitle(ObservationGroupDTO.getTitle()) + .setPurpose(ObservationGroupDTO.getPurpose()); + } + + public static ObservationGroupDTO toObservationGroupDTO_V1(ObservationGroup ObservationGroup) { + Instant instant = ObservationGroup.getModified(); + Instant instant1 = ObservationGroup.getCreated(); + return new ObservationGroupDTO() + .studyId(ObservationGroup.getStudyId()) + .observationGroupId(ObservationGroup.getObservationGroupId()) + .title(ObservationGroup.getTitle()) + .purpose(ObservationGroup.getPurpose()) + .created(instant1) + .modified(instant); + } + + + +} diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ObservationTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ObservationTransformer.java index 56baf62a..9f6843a3 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ObservationTransformer.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ObservationTransformer.java @@ -31,7 +31,8 @@ public static Observation fromObservationDTO_V1(ObservationDTO dto) { .setProperties(MapperUtils.MAPPER.convertValue(dto.getProperties(), ObservationProperties.class)) .setSchedule(EventTransformer.fromObservationScheduleDTO_V1(dto.getSchedule())) .setHidden(dto.getHidden()) - .setNoSchedule(dto.getNoSchedule()); + .setNoSchedule(dto.getNoSchedule()) + .setObservationGroupId(dto.getObservationGroupId()); } public static ObservationDTO toObservationDTO_V1(Observation observation) { @@ -50,7 +51,8 @@ public static ObservationDTO toObservationDTO_V1(Observation observation) { .created(instant1) .modified(instant) .hidden(observation.getHidden()) - .noSchedule(observation.getNoSchedule()); + .noSchedule(observation.getNoSchedule()) + .observationGroupId(observation.getObservationGroupId()); } } diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ParticipantTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ParticipantTransformer.java index fc2c0484..afcbc37d 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ParticipantTransformer.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ParticipantTransformer.java @@ -25,7 +25,8 @@ public static Participant fromParticipantDTO_V1(ParticipantDTO participantDTO) { .setStudyId(participantDTO.getStudyId()) .setParticipantId(participantDTO.getParticipantId()) .setAlias(participantDTO.getAlias()) - .setStudyGroupId(participantDTO.getStudyGroupId()); + .setStudyGroupId(participantDTO.getStudyGroupId()) + .setObservationGroupIds(participantDTO.getObservationGroupIds()); } public static ParticipantDTO toParticipantDTO_V1(Participant participant, GatewayProperties gatewayProps) { @@ -42,6 +43,7 @@ public static ParticipantDTO toParticipantDTO_V1(Participant participant, Gatewa .registrationToken(participant.getRegistrationToken()) .registrationUrl(registrationUri) .status(ParticipantStatusDTO.fromValue(participant.getStatus().getValue())) + .observationGroupIds(participant.getObservationGroupIds()) .start(instant2) .modified(instant1) .created(instant); diff --git a/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml b/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml index 471246cd..7d24775c 100644 --- a/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml +++ b/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml @@ -476,6 +476,110 @@ paths: '404': description: Not found + /studies/{studyId}/observationGroups: + post: + tags: + - observationGroups + operationId: createObservationGroup + description: Create a new observation group for a study + parameters: + - $ref: '#/components/parameters/StudyId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ObservationGroup' + responses: + '201': + description: Observation group successfully created + content: + application/json: + schema: + $ref: '#/components/schemas/ObservationGroup' + '400': + description: Observation group creation failed because of a bad request + '500': + description: Error while creating the observation group + get: + tags: + - observationGroups + description: List all observation groups for a study + operationId: listObservationGroups + parameters: + - $ref: '#/components/parameters/StudyId' + responses: + '200': + description: Successfully listed all study groups + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ObservationGroup' + '404': + description: No study for the parsed studyId found + + /studies/{studyId}/observationGroups/{observationGroupId}: + get: + tags: + - observationGroups + description: Get observation group information + operationId: getObservationGroup + parameters: + - $ref: '#/components/parameters/StudyId' + - $ref: '#/components/parameters/ObservationGroupId' + responses: + '200': + description: Successfully returned observation group information + content: + application/json: + schema: + $ref: '#/components/schemas/ObservationGroup' + '404': + description: Not found + put: + tags: + - observationGroups + description: Update a observation group + operationId: updateObservationGroup + parameters: + - $ref: '#/components/parameters/StudyId' + - $ref: '#/components/parameters/ObservationGroupId' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ObservationGroup' + responses: + '200': + description: Successfully updated observation group + content: + application/json: + schema: + $ref: '#/components/schemas/ObservationGroup' + '400': + description: Could not update study group because of a bad request + '404': + description: Not found + '500': + description: Error while updating the observation group + delete: + tags: + - observationGroups + description: Delete a observation group + operationId: deleteObservationGroup + parameters: + - $ref: '#/components/parameters/StudyId' + - $ref: '#/components/parameters/ObservationGroupId' + responses: + '200': + description: Observation group deleted + '404': + description: Not found + '500': + description: Error while deleting the observation group + /studies/{studyId}/participants: post: tags: @@ -1615,6 +1719,28 @@ components: type: string format: date-time readOnly: true + ObservationGroup: + type: object + properties: + studyId: + $ref: '#/components/schemas/StudyId' + observationGroupId: + $ref: '#/components/schemas/Id' + title: + type: string + purpose: + type: string + numberOfParticipants: + type: integer + readOnly: true + created: + type: string + format: date-time + readOnly: true + modified: + type: string + format: date-time + readOnly: true StudyDuration: type: object properties: @@ -1651,6 +1777,12 @@ components: type: string format: date-time readOnly: true + observationGroupIds: + type: array + description: The set of observation group Ids the participant is part of + items: + $ref: '#/components/schemas/IdReference' + uniqueItems: true created: type: string format: date-time @@ -1714,6 +1846,8 @@ components: noSchedule: type: boolean default: false + observationGroupId: + $ref: '#/components/schemas/IdReference' StudyTimeline: @@ -1913,6 +2047,8 @@ components: type: array items: $ref: '#/components/schemas/Action' + observationGroupId: + $ref: '#/components/schemas/IdReference' created: type: string format: date-time @@ -2364,6 +2500,13 @@ components: type: integer format: int32 required: true + ObservationGroupId: + name: observationGroupId + in: path + schema: + type: integer + format: int32 + required: true ParticipantId: name: participantId in: path diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/InterventionControllerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/InterventionControllerTest.java index 3106313b..f74686c8 100644 --- a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/InterventionControllerTest.java +++ b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/InterventionControllerTest.java @@ -92,6 +92,7 @@ void testAddIntervention() throws Exception { .setSchedule(new Event() .setDateStart(dateStart) .setDateEnd(dateEnd)) + .setObservationGroupId(((Intervention)invocationOnMock.getArgument(0)).getObservationGroupId()) .setCreated(Instant.now()) .setModified(Instant.now())); @@ -103,7 +104,8 @@ void testAddIntervention() throws Exception { .studyGroupId(1) .schedule(new EventDTO() .dtstart(dateStart) - .dtend(dateEnd)); + .dtend(dateEnd)) + .observationGroupId(1); mvc.perform(post("/api/v1/studies/1/interventions") .content(mapper.writeValueAsString(interventionRequest)) @@ -112,7 +114,8 @@ void testAddIntervention() throws Exception { .andExpect(status().isCreated()) .andExpect(jsonPath("$.title").value(interventionRequest.getTitle())) .andExpect(jsonPath("$.interventionId").value(interventionRequest.getInterventionId())) - .andExpect(jsonPath("$.schedule").exists()); + .andExpect(jsonPath("$.schedule").exists()) + .andExpect(jsonPath("$.observationGroupId").value(interventionRequest.getObservationGroupId())); } @Test diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ObservationControllerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ObservationControllerTest.java index 61d50ef7..9e2dca24 100644 --- a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ObservationControllerTest.java +++ b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ObservationControllerTest.java @@ -94,7 +94,8 @@ void testAddObservation() throws Exception { .setStudyGroupId(((Observation)invocationOnMock.getArgument(0)).getStudyGroupId()) .setCreated(Instant.ofEpochMilli(System.currentTimeMillis())) .setModified(Instant.ofEpochMilli(System.currentTimeMillis())) - .setHidden(((Observation) invocationOnMock.getArgument(0)).getHidden())); + .setHidden(((Observation) invocationOnMock.getArgument(0)).getHidden()) + .setObservationGroupId(((Observation) invocationOnMock.getArgument(0)).getObservationGroupId())); ObservationDTO observationRequest = new ObservationDTO() .title("observation 1") @@ -105,7 +106,8 @@ void testAddObservation() throws Exception { .type("accelerometer") .properties(Map.of("name", "value")) .studyGroupId(1) - .hidden(null); + .hidden(null) + .observationGroupId(1); mvc.perform(post("/api/v1/studies/1/observations") .content(mapper.writeValueAsString(observationRequest)) @@ -116,7 +118,8 @@ void testAddObservation() throws Exception { .andExpect(jsonPath("$.observationId").value(observationRequest.getObservationId())) .andExpect(jsonPath("$.type").value(observationRequest.getType())) .andExpect(jsonPath("$.properties.name").value("value")) - .andExpect(jsonPath("$.hidden").value(observationRequest.getHidden())); + .andExpect(jsonPath("$.hidden").value(observationRequest.getHidden())) + .andExpect(jsonPath("$.observationGroupId").value(observationRequest.getObservationGroupId())); } @Test diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ObservationGroupControllerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ObservationGroupControllerTest.java new file mode 100644 index 00000000..3c409ff2 --- /dev/null +++ b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ObservationGroupControllerTest.java @@ -0,0 +1,219 @@ +/* + * 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 + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Elastic License 2.0. + */ +package io.redlink.more.studymanager.controller.studymanager; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.redlink.more.studymanager.api.v1.model.EndpointTokenDTO; +import io.redlink.more.studymanager.api.v1.model.ObservationGroupDTO; +import io.redlink.more.studymanager.model.*; +import io.redlink.more.studymanager.service.OAuth2AuthenticationService; +import io.redlink.more.studymanager.service.ObservationGroupService; +import org.assertj.core.api.Assertions; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.*; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest({ObservationGroupApiV1Controller.class}) +@AutoConfigureMockMvc(addFilters = false) +class ObservationGroupControllerTest { + + + @MockitoBean + ObservationGroupService observationGroupService; + + @MockitoBean + OAuth2AuthenticationService oAuth2AuthenticationService; + + @Autowired + ObjectMapper mapper; + + @Autowired + private MockMvc mvc; + + @BeforeEach + void setUp() { + when(oAuth2AuthenticationService.getCurrentUser()).thenReturn( + new AuthenticatedUser( + UUID.randomUUID().toString(), + "Test User", "test@example.com", "Test Inc.", + EnumSet.allOf(PlatformRole.class) + ) + ); + } + + @Test + @DisplayName("Create ObservationGroup") + void testCreateObservationGroup() throws Exception { + when(observationGroupService.createObservationGroup(any(ObservationGroup.class))) + .thenAnswer(invocationOnMock -> new ObservationGroup() + .setStudyId(((ObservationGroup)invocationOnMock.getArgument(0)).getStudyId()) + .setObservationGroupId(1) + .setTitle(((ObservationGroup)invocationOnMock.getArgument(0)).getTitle()) + .setPurpose(((ObservationGroup)invocationOnMock.getArgument(0)).getPurpose()) + .setCreated(Instant.ofEpochMilli(System.currentTimeMillis())) + .setModified(Instant.ofEpochMilli(System.currentTimeMillis()))); + + ObservationGroupDTO observationGroupRequestBody = new ObservationGroupDTO() + .studyId(13L) //expected to be overridden by the controller to the id parts as path + .title("observation group 1") + .purpose("for testing only"); + + mvc.perform(post("/api/v1/studies/1/observationGroups") + .content(mapper.writeValueAsString(observationGroupRequestBody)) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.studyId").value(1L)) + .andExpect(jsonPath("$.observationGroupId").value(1)) + .andExpect(jsonPath("$.title").value(observationGroupRequestBody.getTitle())) + .andExpect(jsonPath("$.purpose").value(observationGroupRequestBody.getPurpose())) + .andExpect(jsonPath("$.created").exists()) + .andExpect(jsonPath("$.modified").exists()); + } + + @Test + @DisplayName("Update observation group") + void testUpdateObservationGroup() throws Exception { + when(observationGroupService.updateObservationGroup(any(ObservationGroup.class))).thenAnswer(invocationOnMock -> + new ObservationGroup() + .setStudyId(((ObservationGroup)invocationOnMock.getArgument(0)).getStudyId()) + .setObservationGroupId(((ObservationGroup)invocationOnMock.getArgument(0)).getObservationGroupId()) + .setTitle(((ObservationGroup)invocationOnMock.getArgument(0)).getTitle()) + .setPurpose(((ObservationGroup)invocationOnMock.getArgument(0)).getPurpose()) + .setCreated(Instant.now().minusSeconds(100)) + .setModified(Instant.now().minusSeconds(50))); + + + ObservationGroupDTO observationRequest = new ObservationGroupDTO() + .studyId(13L) //need to be ignored as this is specified via path parameters + .observationGroupId(13) //need to be ignored as this is specified via path parameters + .title("the updated title") + .purpose("the purpose of life") + .created(Instant.now().minusSeconds(100)); + + mvc.perform(put("/api/v1/studies/1/observationGroups/1") + .content(mapper.writeValueAsString(observationRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.studyId").value(1)) //as in the path + .andExpect(jsonPath("$.observationGroupId").value(1)) //as in the path + .andExpect(jsonPath("$.title").value(observationRequest.getTitle())) + .andExpect(jsonPath("$.purpose").value(observationRequest.getPurpose())) + .andExpect(jsonPath("$.modified").exists()) + .andExpect(jsonPath("$.created").exists()); + } + + @Test + @DisplayName("List all observation groups for a study") + void testListObservationGroup() throws Exception { + when(observationGroupService.listObservationGroups(any(Long.class))).thenAnswer(invocationOnMock -> { + Long studyId = ((Long) invocationOnMock.getArgument(0)); + Assertions.assertThat(studyId).isEqualTo(1L); + return List.of( + new ObservationGroup() + .setStudyId(studyId) + .setObservationGroupId(1) + .setTitle("Observation group 1") + .setPurpose("purpose 1") + .setCreated(Instant.now().minusSeconds(100)) + .setModified(Instant.now().minusSeconds(50)), + new ObservationGroup() + .setStudyId(studyId) + .setObservationGroupId(2) + .setTitle("Observation group 2") + .setPurpose("purpose 2") + .setCreated(Instant.now().minusSeconds(25)) + .setModified(Instant.now().minusSeconds(12)), + new ObservationGroup() + .setStudyId(studyId) + .setObservationGroupId(3) + .setTitle("Observation group 3") + .setPurpose("purpose 3") + .setCreated(Instant.now().minusSeconds(6)) + .setModified(Instant.now().minusSeconds(3)) + ); + }); + + mvc.perform(get("/api/v1/studies/1/observationGroups")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$", Matchers.hasSize(3))) + .andExpect(jsonPath("$[0].studyId").value(1)) + .andExpect(jsonPath("$[0].observationGroupId").value(1)) + .andExpect(jsonPath("$[0].title").value("Observation group 1")) + .andExpect(jsonPath("$[0].purpose").value("purpose 1")) + .andExpect(jsonPath("$[0].created").exists()) + .andExpect(jsonPath("$[0].modified").exists()) + .andExpect(jsonPath("$[1].studyId").value(1)) + .andExpect(jsonPath("$[1].observationGroupId").value(2)) + .andExpect(jsonPath("$[2].studyId").value(1)) + .andExpect(jsonPath("$[2].observationGroupId").value(3)); + } + + @Test + @DisplayName("Get Observation Group") + void testGetObservationGroup() throws Exception{ + when(observationGroupService.getObservationGroup(any(Long.class),any(Integer.class))).thenAnswer(invocationOnMock -> { + Long studyId = ((Long) invocationOnMock.getArgument(0)); + Integer observationGroup = ((Integer) invocationOnMock.getArgument(1)); + return new ObservationGroup() + .setStudyId(studyId) + .setObservationGroupId(observationGroup) + .setTitle("Observation group " + observationGroup + " of study " + studyId) + .setPurpose("purpose of observation group " + observationGroup + " of study " + studyId) + .setCreated(Instant.now().minusSeconds(100)) + .setModified(Instant.now().minusSeconds(50)); + }); + + mvc.perform(get("/api/v1/studies/3/observationGroups/23")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.studyId").value(3L)) + .andExpect(jsonPath("$.observationGroupId").value(23)) + .andExpect(jsonPath("$.title").value("Observation group 23 of study 3")) + .andExpect(jsonPath("$.purpose").value("purpose of observation group 23 of study 3")) + .andExpect(jsonPath("$.created").exists()) + .andExpect(jsonPath("$.modified").exists()); + } + @Test + @DisplayName("Delete Observation Group") + void testDeleteObservationGroup() throws Exception{ + Mockito.doNothing().when(observationGroupService).deleteObservationGroup(any(Long.class),any(Integer.class)); + + mvc.perform(delete("/api/v1/studies/3/observationGroups/23")) + .andDo(print()) + .andExpect(status().isNoContent()); + + Mockito.verify(observationGroupService, times(1)).deleteObservationGroup(eq(3L), eq(23)); + } +} + + + diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ParticipantControllerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ParticipantControllerTest.java index 39ac2e2d..ace7c6e8 100644 --- a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ParticipantControllerTest.java +++ b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ParticipantControllerTest.java @@ -21,6 +21,7 @@ import java.time.Instant; import java.util.EnumSet; import java.util.Random; +import java.util.Set; import java.util.UUID; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.BeforeEach; @@ -100,12 +101,14 @@ void testCreateParticipant() throws Exception { .setStatus(Participant.Status.NEW) .setCreated(Instant.ofEpochMilli(System.currentTimeMillis())) .setModified(Instant.ofEpochMilli(System.currentTimeMillis())) - .setRegistrationToken("TEST123")); + .setRegistrationToken("TEST123") + .setObservationGroupIds(Set.of(1, 2))); ParticipantDTO participantRequest = new ParticipantDTO() .studyId(studyId) .alias("participant x") - .studyGroupId(1); + .studyGroupId(1) + .observationGroupIds(Set.of(1,2)); ParticipantDTO[] participantDTOS = new ParticipantDTO[]{participantRequest}; @@ -118,7 +121,10 @@ void testCreateParticipant() throws Exception { .andExpect(jsonPath("$[0].participantId").value(1)) .andExpect(jsonPath("$[0].status").value("new")) .andExpect(jsonPath("$[0].studyGroupId").value(participantRequest.getStudyGroupId())) - .andExpect(jsonPath("$[0].registrationToken").exists()); + .andExpect(jsonPath("$[0].registrationToken").exists()) + .andExpect(jsonPath("$[0].observationGroupIds").isArray()) + .andExpect(jsonPath("$[0].observationGroupIds[0]").value(1)) + .andExpect(jsonPath("$[0].observationGroupIds[1]").value(2)); } @Test From 06a9d97cdae46190db45e1f01a3cc740eeb4c6f4 Mon Sep 17 00:00:00 2001 From: Rupert Westenthaler Date: Wed, 17 Dec 2025 07:11:13 +0100 Subject: [PATCH 2/4] #180: Updates the Businesslogic to support ObservationGroups * Participant Timelines now use the participants observation groups to query for relevant Observations and interventions * Import/Export of study configuration now includes observation groups. ParticipantInfo also include ObservationGroup assignment Extended all relevant tests to assert the changed and extended behavior --- .../studymanager/model/StudyImportExport.java | 14 +++- .../UpsertOccurredObservationsCron.java | 2 +- .../studymanager/service/CalendarService.java | 30 ++++---- .../service/InterventionService.java | 10 ++- .../service/ObservationService.java | 4 +- .../UpsertOccurredObservationsCronTest.java | 2 +- .../service/CalendarServiceTest.java | 15 ++-- .../studymanager/CalendarApiV1Controller.java | 8 ++- .../transformer/ImportExportTransformer.java | 10 ++- .../service/ImportExportService.java | 30 +++++--- .../resources/openapi/StudyManagerAPI.yaml | 13 ++++ .../studymanager/CalendarControllerTest.java | 70 +++++++++---------- .../ParticipantControllerTest.java | 5 +- .../service/ImportExportServiceTest.java | 64 +++++++++++++---- 14 files changed, 182 insertions(+), 95 deletions(-) diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/model/StudyImportExport.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/model/StudyImportExport.java index 656f0ac4..bec20901 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/model/StudyImportExport.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/model/StudyImportExport.java @@ -10,11 +10,13 @@ import java.util.List; import java.util.Map; +import java.util.Set; public class StudyImportExport { private Study study; private List studyGroups; + private List observationGroups; private List observations; private List interventions; private List participants; @@ -40,6 +42,15 @@ public StudyImportExport setStudyGroups(List studyGroups) { return this; } + public List getObservationGroups() { + return observationGroups; + } + + public StudyImportExport setObservationGroups(List observationGroups) { + this.observationGroups = observationGroups; + return this; + } + public List getObservations() { return observations; } @@ -95,6 +106,7 @@ public StudyImportExport setIntegrations(List integrations) { } public record ParticipantInfo( - Integer groupId + Integer groupId, + Set observationGroupIds ) {} } diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/scheduling/UpsertOccurredObservationsCron.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/scheduling/UpsertOccurredObservationsCron.java index 46d64838..d00076bc 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/scheduling/UpsertOccurredObservationsCron.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/scheduling/UpsertOccurredObservationsCron.java @@ -73,7 +73,7 @@ private void upsertOccurredObservations(Study study) { for(Participant participant : participants) { ctx.putParticipant(participant); //NOTE: from, to are currently not supported - var timeline = calendarService.getTimeline(study, participant, null, null, null, null); + var timeline = calendarService.getTimeline(study, participant, null, null, null, null, null); for(ObservationTimelineEvent event : timeline.observationTimelineEvents()){ var start = event.start().truncatedTo(ChronoUnit.MINUTES); if((lastOccurredObservation == null || start.isAfter(lastOccurredObservation)) && start.isBefore(current)){ diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/CalendarService.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/CalendarService.java index 04e97ce3..5e0a0978 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/CalendarService.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/CalendarService.java @@ -24,9 +24,7 @@ import java.time.LocalTime; import java.time.ZoneId; import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; import org.apache.commons.lang3.Range; import org.springframework.stereotype.Service; @@ -48,7 +46,7 @@ public CalendarService(StudyService studyService, ObservationService observation this.participantService = participantService; } - public StudyTimeline getTimeline(Long studyId, Integer participantId, Integer studyGroupId, Instant referenceDate, LocalDate from, LocalDate to) { + public StudyTimeline getTimeline(Long studyId, Integer participantId, Integer studyGroupId, Collection observationGroupIds, Instant referenceDate, LocalDate from, LocalDate to) { final Study study = studyService.getStudy(studyId, null) .orElseThrow(() -> NotFoundException.Study(studyId)); final Participant participant; @@ -58,10 +56,10 @@ public StudyTimeline getTimeline(Long studyId, Integer participantId, Integer st } else { participant = null; } - return getTimeline(study, participant, studyGroupId, referenceDate, from, to); + return getTimeline(study, participant, studyGroupId, observationGroupIds, referenceDate, from, to); } - public StudyTimeline getTimeline(Study study, Participant participant, Integer studyGroupId, Instant referenceDate, LocalDate from, LocalDate to) { + public StudyTimeline getTimeline(Study study, Participant participant, Integer studyGroupId, Collection observationGroupIds, Instant referenceDate, LocalDate from, LocalDate to) { final Range studyRange = Range.of( Objects.requireNonNullElse(study.getStartDate(), study.getPlannedStartDate()), Objects.requireNonNullElse(study.getEndDate(), study.getPlannedEndDate()), @@ -88,26 +86,32 @@ public StudyTimeline getTimeline(Study study, Participant participant, Integer s } /* - * effectiveGroup: + * effectiveStudyGroups: * (1) participant.group (if participant is provided) * (2) studyGroupId (if provided by user and participant is NOT provided) * (3) otherwise */ - final Integer effectiveGroup; + final Integer effectiveStudyGroups; if (participant != null) { - effectiveGroup = participant.getStudyGroupId(); + effectiveStudyGroups = participant.getStudyGroupId(); } else { - effectiveGroup = studyGroupId; + effectiveStudyGroups = studyGroupId; + } + final Collection effectiveObservationGroups; + if (participant != null) { + effectiveObservationGroups = participant.getObservationGroupIds(); + } else { + effectiveObservationGroups = observationGroupIds == null ? Collections.emptyList() : observationGroupIds; } - final List observations = observationService.listObservationsForGroup(study.getStudyId(), effectiveGroup); - final List interventions = interventionService.listInterventionsForGroup(study.getStudyId(), effectiveGroup); + final List observations = observationService.listObservationsForGroup(study.getStudyId(), effectiveStudyGroups, effectiveObservationGroups); + final List interventions = interventionService.listInterventionsForGroup(study.getStudyId(), effectiveStudyGroups, effectiveObservationGroups); // Shift the effective study-start if the participant would miss a relative observation final LocalDate firstDayInStudy = SchedulerUtils.alignStartDateToSignupInstant(participantStart, observations); // now how long does the study run? - final Duration studyDuration = Optional.ofNullable(effectiveGroup) + final Duration studyDuration = Optional.ofNullable(effectiveStudyGroups) .flatMap(eg -> studyService.getStudyDuration(study.getStudyId(), eg)) .or(() -> Optional.of(study.getDuration())) .or(() -> Optional.of(new Duration() diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/InterventionService.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/InterventionService.java index 4080e291..aff4942e 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/InterventionService.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/InterventionService.java @@ -25,10 +25,8 @@ import io.redlink.more.studymanager.sdk.MoreSDK; import io.redlink.more.studymanager.utils.LoggingUtils; import java.text.ParseException; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; +import java.util.*; + import org.quartz.CronExpression; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -93,8 +91,8 @@ public List listInterventions(Long studyId) { return repository.listInterventions(studyId); } - public List listInterventionsForGroup(Long studyId, Integer groupId) { - return repository.listInterventionsForGroup(studyId, groupId); + public List listInterventionsForGroup(Long studyId, Integer studyGroupId, Collection observationGroupIds) { + return repository.listInterventionsForGroup(studyId, studyGroupId, observationGroupIds); } public Intervention getIntervention(Long studyId, Integer interventionId) { diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/ObservationService.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/ObservationService.java index e6cd04dd..8ad791dd 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/ObservationService.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/ObservationService.java @@ -78,8 +78,8 @@ public List listObservations(Long studyId) { return repository.listObservations(studyId); } - public List listObservationsForGroup(Long studyId, Integer groupId) { - return repository.listObservationsForGroup(studyId, groupId); + public List listObservationsForGroup(Long studyId, Integer studyGroupId, Collection observationGroupIds) { + return repository.listObservationsForGroup(studyId, studyGroupId, observationGroupIds); } public Observation updateObservation(Observation observation) { diff --git a/studymanager-services/src/test/java/io/redlink/more/studymanager/scheduling/UpsertOccurredObservationsCronTest.java b/studymanager-services/src/test/java/io/redlink/more/studymanager/scheduling/UpsertOccurredObservationsCronTest.java index fb179901..4bb582ff 100644 --- a/studymanager-services/src/test/java/io/redlink/more/studymanager/scheduling/UpsertOccurredObservationsCronTest.java +++ b/studymanager-services/src/test/java/io/redlink/more/studymanager/scheduling/UpsertOccurredObservationsCronTest.java @@ -97,7 +97,7 @@ public void testUpsert() { when(participantService.listParticipants(study2.getStudyId())) .thenReturn(List.of(study2Par1, study2Par2)); - when(calendarService.getTimeline(any(Study.class), any(Participant.class), any(), any(), any(), any())) + when(calendarService.getTimeline(any(Study.class), any(Participant.class), any(), any(), any(), any(), any())) .thenAnswer(invocationOnMock -> { Long studyId = invocationOnMock.getArgument(0, Study.class).getStudyId(); Integer participantId = invocationOnMock.getArgument(1, Participant.class).getParticipantId(); diff --git a/studymanager-services/src/test/java/io/redlink/more/studymanager/service/CalendarServiceTest.java b/studymanager-services/src/test/java/io/redlink/more/studymanager/service/CalendarServiceTest.java index 4762d820..1cbfc953 100644 --- a/studymanager-services/src/test/java/io/redlink/more/studymanager/service/CalendarServiceTest.java +++ b/studymanager-services/src/test/java/io/redlink/more/studymanager/service/CalendarServiceTest.java @@ -21,6 +21,8 @@ import java.time.ZoneId; import java.util.List; import java.util.Optional; +import java.util.Set; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -53,7 +55,7 @@ class CalendarServiceTest { @Test void testStudyNotFound() { when(studyService.getStudy(any(), any())).thenReturn(Optional.empty()); - assertThrows(NotFoundException.class, () -> calendarService.getTimeline(1L, 1, 1, Instant.now(), LocalDate.now(), LocalDate.now())); + assertThrows(NotFoundException.class, () -> calendarService.getTimeline(1L, 1, 1, Set.of(), Instant.now(), LocalDate.now(), LocalDate.now())); } @Test @@ -66,7 +68,7 @@ void testParticipantNotFound() { .setPlannedStartDate(LocalDate.now()) .setPlannedEndDate(LocalDate.now().plusDays(3)) )); - assertThrows(NotFoundException.class, () -> calendarService.getTimeline(1L, 1,1, Instant.now(), LocalDate.now(), LocalDate.now())); + assertThrows(NotFoundException.class, () -> calendarService.getTimeline(1L, 1,1, Set.of(), Instant.now(), LocalDate.now(), LocalDate.now())); } @Test @@ -78,7 +80,7 @@ void testGetTimeline() { .setPlannedEndDate(LocalDate.of(2024, 5, 14)) .setDuration(new Duration().setUnit(Duration.Unit.DAY).setValue(5)); - Participant participant = new Participant().setStudyGroupId(2); + Participant participant = new Participant().setStudyGroupId(2).setObservationGroupIds(Set.of(1,2)); Observation observationAbsolute = new Observation() .setObservationId(1) @@ -165,10 +167,10 @@ void testGetTimeline() { when(participantService.getParticipant(any(), any())).thenReturn(participant); when(studyService.getStudyDuration(any(), any())) .thenReturn(Optional.of(new Duration().setValue(5).setUnit(Duration.Unit.DAY))); - when(observationService.listObservationsForGroup(any(), eq(participant.getStudyGroupId()))).thenReturn( + when(observationService.listObservationsForGroup(any(), eq(participant.getStudyGroupId()),eq(participant.getObservationGroupIds()))).thenReturn( List.of(observationAbsolute, observationAbsoluteRecurrent, observationRelative, observationRelativeRecurrent)); - when(interventionService.listInterventionsForGroup(any(), eq(participant.getStudyGroupId()))).thenReturn( + when(interventionService.listInterventionsForGroup(any(), eq(participant.getStudyGroupId()), eq(participant.getObservationGroupIds()))).thenReturn( List.of(scheduledIntervention, relativeIntervention)); when(interventionService.getTriggerByIds(any(), eq(1))).thenReturn(relativeTrigger); when(interventionService.getTriggerByIds(any(), eq(2))).thenReturn(scheduledTrigger); @@ -176,7 +178,8 @@ void testGetTimeline() { StudyTimeline timeline = calendarService.getTimeline( 1L, 1, - 2, + -2, //ignored ... taken from participant + Set.of(-1,-2), //ignored ... taken from participant LocalDateTime.of( LocalDate.of(2024, 5, 11), LocalTime.of(10,10,10) diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java index 498a1e2a..552386c7 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java @@ -24,6 +24,7 @@ import java.time.Instant; import java.time.LocalDate; +import java.util.Set; @RestController @RequestMapping(value = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE) @@ -50,10 +51,13 @@ public ResponseEntity getStudyCalendar(Long studyId) { @Override @RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR}) @Audited - public ResponseEntity getStudyTimeline(Long studyId, Integer participant, Integer studyGroup, Instant referenceDate, LocalDate from, LocalDate to) { + public ResponseEntity getStudyTimeline(Long studyId, Integer participant, Integer studyGroup, Integer observationGroup, Instant referenceDate, LocalDate from, LocalDate to) { return ResponseEntity.ok( TimelineTransformer.toStudyTimelineDTO( - service.getTimeline(studyId, participant, studyGroup, referenceDate, from, to) + service.getTimeline( + studyId, participant, studyGroup, + observationGroup == null ? Set.of() : Set.of(observationGroup), + referenceDate, from, to) ) ); } diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ImportExportTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ImportExportTransformer.java index a4979f67..40030651 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ImportExportTransformer.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ImportExportTransformer.java @@ -14,7 +14,10 @@ import io.redlink.more.studymanager.api.v1.model.StudyImportExportDTO; import io.redlink.more.studymanager.model.IntegrationInfo; import io.redlink.more.studymanager.model.StudyImportExport; + +import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -71,11 +74,14 @@ public static StudyImportExportDTO toStudyImportExportDTO_V1(StudyImportExport s private static ParticipantInfoDTO toParticipantDTO_V1(StudyImportExport.ParticipantInfo participant) { return new ParticipantInfoDTO() - .studyGroup(participant.groupId()); + .studyGroup(participant.groupId()) + .observationGroups(participant.observationGroupIds()); } private static StudyImportExport.ParticipantInfo fromParticipantDTO_V1(ParticipantInfoDTO participant) { - return new StudyImportExport.ParticipantInfo(participant.getStudyGroup()); + return new StudyImportExport.ParticipantInfo( + participant.getStudyGroup(), + participant.getObservationGroups() == null ? Set.of() : participant.getObservationGroups()); } private static List transform(List list, Function transformer) { diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/service/ImportExportService.java b/studymanager/src/main/java/io/redlink/more/studymanager/service/ImportExportService.java index abd09686..a217b8b1 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/service/ImportExportService.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/service/ImportExportService.java @@ -39,6 +39,7 @@ public class ImportExportService { private final ObservationService observationService; private final InterventionService interventionService; private final StudyGroupService studyGroupService; + private final ObservationGroupService observationGroupService; private final IntegrationService integrationService; private final ElasticService elasticService; @@ -46,6 +47,7 @@ public class ImportExportService { public ImportExportService(ParticipantService participantService, StudyService studyService, StudyStateService studyStateService, ObservationService observationService, InterventionService interventionService, StudyGroupService studyGroupService, + ObservationGroupService observationGroupService, IntegrationService integrationService, ElasticService elasticService, GatewayProperties gatewayProperties) { this.participantService = participantService; this.studyService = studyService; @@ -53,6 +55,7 @@ public ImportExportService(ParticipantService participantService, StudyService s this.observationService = observationService; this.interventionService = interventionService; this.studyGroupService = studyGroupService; + this.observationGroupService = observationGroupService; this.integrationService = integrationService; this.elasticService = elasticService; this.gatewayProperties = gatewayProperties; @@ -95,6 +98,7 @@ public StudyImportExport exportStudy(Long studyId, User user) { .setStudy(studyService.getStudy(studyId, user) .orElseThrow(() -> new NotFoundException("study", studyId))) .setStudyGroups(studyGroupService.listStudyGroups(studyId)) + .setObservationGroups(observationGroupService.listObservationGroups(studyId)) .setObservations(observationService.listObservations(studyId)) .setInterventions(interventionService.listInterventions(studyId)) .setActions(new HashMap<>()) @@ -105,7 +109,9 @@ public StudyImportExport exportStudy(Long studyId, User user) { export.setParticipants(participantService.listParticipants(studyId) .stream() .sorted(Comparator.comparing(Participant::getParticipantId)) - .map(participant -> new StudyImportExport.ParticipantInfo(participant.getStudyGroupId())) + .map(participant -> new StudyImportExport.ParticipantInfo( + participant.getStudyGroupId(), + participant.getObservationGroupIds())) .toList() ); @@ -133,29 +139,31 @@ public Study importStudy(StudyImportExport studyImport, AuthenticatedUser user) final Long studyId = newStudy.getStudyId(); studyImport.getStudyGroups().forEach(studyGroup -> - studyGroupService.importStudyGroup(studyId, studyGroup) - ); + studyGroupService.importStudyGroup(studyId, studyGroup)); + + studyImport.getObservationGroups().forEach(observationGroup -> + observationGroupService.importObservationGroup(studyId, observationGroup)); + studyImport.getObservations().forEach(observation -> - observationService.importObservation(studyId, observation) - ); + observationService.importObservation(studyId, observation)); + studyImport.getInterventions().forEach(intervention -> interventionService.importIntervention( studyId, intervention, studyImport.getTriggers().get(intervention.getInterventionId()), - studyImport.getActions().getOrDefault(intervention.getInterventionId(), Collections.emptyList()) - ) - ); + studyImport.getActions().getOrDefault(intervention.getInterventionId(), Collections.emptyList()))); + studyImport.getParticipants().forEach(participant -> participantService.createParticipant( new Participant() .setStudyId(studyId) .setAlias("Participant") .setStudyGroupId(participant.groupId()) - )); + .setObservationGroupIds(participant.observationGroupIds()))); + studyImport.getIntegrations().forEach(integration -> - integrationService.addToken(studyId, integration.observationId(), integration.name()) - ); + integrationService.addToken(studyId, integration.observationId(), integration.name())); return newStudy; } diff --git a/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml b/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml index 7d24775c..77dbfdd2 100644 --- a/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml +++ b/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml @@ -350,6 +350,10 @@ paths: in: query schema: $ref: '#/components/schemas/Id' + - name: observationGroup + in: query + schema: + $ref: '#/components/schemas/Id' - name: referenceDate in: query description: reference date used to calculate relative schedules @@ -2118,6 +2122,10 @@ components: type: array items: $ref: '#/components/schemas/StudyGroup' + observationGroups: + type: array + items: + $ref: '#/components/schemas/ObservationGroup' observations: type: array items: @@ -2140,6 +2148,11 @@ components: properties: studyGroup: type: integer + observationGroups: + type: array + items: + type: integer + uniqueItems: true IntegrationInfo: type: object diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/CalendarControllerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/CalendarControllerTest.java index 163172f6..3ca00c43 100644 --- a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/CalendarControllerTest.java +++ b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/CalendarControllerTest.java @@ -64,42 +64,40 @@ void testGetStudyTimeline() throws Exception { LocalDate from = LocalDate.of(2024, 2, 1); LocalDate to = LocalDate.of(2024, 5, 1); - when(service.getTimeline(any(Long.class), any(Integer.class), any(), any(Instant.class), any(LocalDate.class), any(LocalDate.class))) - .thenAnswer(invocationOnMock -> { - return new StudyTimeline( - referenceDate, - Range.of(from, to, LocalDate::compareTo), - List.of( - ObservationTimelineEvent.fromObservation( - new Observation() - .setObservationId(1) - .setStudyId(invocationOnMock.getArgument(0)) - .setStudyGroupId(studyGroup1) - .setTitle("title 1") - .setPurpose("purpose 1") - .setType("type 1") - .setHidden(Boolean.FALSE) - .setSchedule(new Event()), - ((LocalDate)invocationOnMock.getArgument(4)).atStartOfDay(ZoneId.systemDefault()).toInstant(), - ((LocalDate)invocationOnMock.getArgument(5)).atStartOfDay(ZoneId.systemDefault()).toInstant() - ), - ObservationTimelineEvent.fromObservation( - new Observation() - .setObservationId(2) - .setStudyId(invocationOnMock.getArgument(0)) - .setStudyGroupId(studyGroup2) - .setTitle("title 2") - .setPurpose("purpose 2") - .setType("type 2") - .setHidden(Boolean.TRUE) - .setSchedule(new RelativeEvent()), - ((LocalDate)invocationOnMock.getArgument(4)).atStartOfDay(ZoneId.systemDefault()).toInstant(), - ((LocalDate)invocationOnMock.getArgument(5)).atStartOfDay(ZoneId.systemDefault()).toInstant() - ) - ), - List.of() - ); - }); + when(service.getTimeline(any(Long.class), any(Integer.class), any(), any(), any(Instant.class), any(LocalDate.class), any(LocalDate.class))) + .thenAnswer(invocationOnMock -> new StudyTimeline( + referenceDate, + Range.of(from, to, LocalDate::compareTo), + List.of( + ObservationTimelineEvent.fromObservation( + new Observation() + .setObservationId(1) + .setStudyId(invocationOnMock.getArgument(0)) + .setStudyGroupId(studyGroup1) + .setTitle("title 1") + .setPurpose("purpose 1") + .setType("type 1") + .setHidden(Boolean.FALSE) + .setSchedule(new Event()), + ((LocalDate)invocationOnMock.getArgument(5)).atStartOfDay(ZoneId.systemDefault()).toInstant(), + ((LocalDate)invocationOnMock.getArgument(6)).atStartOfDay(ZoneId.systemDefault()).toInstant() + ), + ObservationTimelineEvent.fromObservation( + new Observation() + .setObservationId(2) + .setStudyId(invocationOnMock.getArgument(0)) + .setStudyGroupId(studyGroup2) + .setTitle("title 2") + .setPurpose("purpose 2") + .setType("type 2") + .setHidden(Boolean.TRUE) + .setSchedule(new RelativeEvent()), + ((LocalDate)invocationOnMock.getArgument(5)).atStartOfDay(ZoneId.systemDefault()).toInstant(), + ((LocalDate)invocationOnMock.getArgument(6)).atStartOfDay(ZoneId.systemDefault()).toInstant() + ) + ), + List.of() + )); mvc.perform(get("/api/v1/studies/3/timeline") .param("participant", String.valueOf(2)) diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ParticipantControllerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ParticipantControllerTest.java index ace7c6e8..73d5ae2c 100644 --- a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ParticipantControllerTest.java +++ b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/ParticipantControllerTest.java @@ -123,8 +123,9 @@ void testCreateParticipant() throws Exception { .andExpect(jsonPath("$[0].studyGroupId").value(participantRequest.getStudyGroupId())) .andExpect(jsonPath("$[0].registrationToken").exists()) .andExpect(jsonPath("$[0].observationGroupIds").isArray()) - .andExpect(jsonPath("$[0].observationGroupIds[0]").value(1)) - .andExpect(jsonPath("$[0].observationGroupIds[1]").value(2)); + //NOTE: use Json path to do some kind of contains in any order ... + .andExpect(jsonPath("$[0].observationGroupIds[?(@ == 1)]").exists()) + .andExpect(jsonPath("$[0].observationGroupIds[?(@ == 2)]").exists()); } @Test diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/service/ImportExportServiceTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/service/ImportExportServiceTest.java index a6db7cee..b1dfbfd2 100644 --- a/studymanager/src/test/java/io/redlink/more/studymanager/service/ImportExportServiceTest.java +++ b/studymanager/src/test/java/io/redlink/more/studymanager/service/ImportExportServiceTest.java @@ -54,6 +54,9 @@ public class ImportExportServiceTest { @Spy private StudyGroupService studyGroupService = mock(StudyGroupService.class); + @Spy + private ObservationGroupService observationGroupService = mock(ObservationGroupService.class); + @InjectMocks ImportExportService importExportService; @@ -119,7 +122,8 @@ void testImportStudy() { .setType("gps-mobile-observation") .setStudyGroupId(3) .setProperties(new ObservationProperties()) - .setSchedule(new Event()), + .setSchedule(new Event()) + .setObservationGroupId(1), new Observation() .setObservationId(3) .setTitle("observation Title") @@ -128,7 +132,8 @@ void testImportStudy() { .setType("gps-mobile-observation") .setStudyGroupId(null) .setProperties(new ObservationProperties()) - .setSchedule(new Event()))) + .setSchedule(new Event()) + .setObservationGroupId(2))) .setStudyGroups(List.of( new StudyGroup() .setStudyGroupId(2) @@ -138,19 +143,30 @@ void testImportStudy() { .setStudyGroupId(3) .setTitle("group title2") .setPurpose("group purpose2"))) + .setObservationGroups(List.of( + new ObservationGroup() + .setObservationGroupId(1) + .setTitle("observation group title 1") + .setPurpose("observation group purpose 1"), + new ObservationGroup() + .setObservationGroupId(2) + .setTitle("observation group title 2") + .setPurpose("observation group purpose 2"))) .setInterventions(List.of( new Intervention() .setInterventionId(2) .setTitle("intervention title") .setPurpose("intervention purpose") .setStudyGroupId(2) - .setSchedule(new Event()), + .setSchedule(new Event()) + .setObservationGroupId(1), new Intervention() .setInterventionId(3) .setTitle("intervention title") .setPurpose("intervention purpose") .setStudyGroupId(3) - .setSchedule(new Event()))) + .setSchedule(new Event()) + .setObservationGroupId(2))) .setTriggers(Map.of(3, new Trigger() .setType("sth") .setProperties(new TriggerProperties()))) @@ -158,14 +174,14 @@ void testImportStudy() { .setType("sth") .setProperties(new ActionProperties())))) .setParticipants(List.of( - new StudyImportExport.ParticipantInfo(0), - new StudyImportExport.ParticipantInfo(0), - new StudyImportExport.ParticipantInfo(0), - new StudyImportExport.ParticipantInfo(2), - new StudyImportExport.ParticipantInfo(2), - new StudyImportExport.ParticipantInfo(2), - new StudyImportExport.ParticipantInfo(4), - new StudyImportExport.ParticipantInfo(4) + new StudyImportExport.ParticipantInfo(0, null), + new StudyImportExport.ParticipantInfo(0, Set.of(1)), + new StudyImportExport.ParticipantInfo(0, Set.of(1,2)), + new StudyImportExport.ParticipantInfo(2, Set.of()), + new StudyImportExport.ParticipantInfo(2, Set.of(2)), + new StudyImportExport.ParticipantInfo(2, Set.of(1,2)), + new StudyImportExport.ParticipantInfo(4, Set.of(1)), + new StudyImportExport.ParticipantInfo(4, Set.of(2)) )) .setIntegrations(List.of( new IntegrationInfo("Integration 1", 1), @@ -190,6 +206,13 @@ void testImportStudy() { assertThat(studyGroupCaptor.getAllValues().get(0).getStudyGroupId()).isEqualTo(2); assertThat(studyGroupCaptor.getAllValues().get(1).getStudyGroupId()).isEqualTo(3); + ArgumentCaptor observationGroupCaptor = ArgumentCaptor.forClass(ObservationGroup.class); + verify(observationGroupService, times(2)).importObservationGroup(idLongCaptor.capture(), observationGroupCaptor.capture()); + assertThat(studyGroupCaptor.getAllValues()).hasSize(2); + assertThat(studyGroupCaptor.getAllValues().get(0).getStudyGroupId()).isEqualTo(2); + assertThat(studyGroupCaptor.getAllValues().get(1).getStudyGroupId()).isEqualTo(3); + + verify(observationService, times(2)).importObservation(idLongCaptor.capture(), observationCaptor.capture()); verify(interventionService, times(2)).importIntervention(idLongCaptor.capture(), interventionCaptor.capture(), triggerCaptor.capture(), actionCaptor.capture()); verify(participantService, times(8)).createParticipant(participantsCaptor.capture()); @@ -197,15 +220,32 @@ void testImportStudy() { assertThat(observationCaptor.getAllValues().get(0).getObservationId()).isEqualTo(1); assertThat(observationCaptor.getAllValues().get(0).getStudyGroupId()).isEqualTo(3); + assertThat(observationCaptor.getAllValues().get(0).getObservationGroupId()).isEqualTo(1); assertThat(observationCaptor.getAllValues().get(1).getObservationId()).isEqualTo(3); assertThat(observationCaptor.getAllValues().get(1).getStudyGroupId()).isEqualTo(null); + assertThat(observationCaptor.getAllValues().get(1).getObservationGroupId()).isEqualTo(2); assertThat(interventionCaptor.getAllValues().get(0).getStudyGroupId()).isEqualTo(2); assertThat(interventionCaptor.getAllValues().get(0).getInterventionId()).isEqualTo(2); + assertThat(interventionCaptor.getAllValues().get(0).getObservationGroupId()).isEqualTo(1); assertThat(interventionCaptor.getAllValues().get(1).getStudyGroupId()).isEqualTo(3); assertThat(interventionCaptor.getAllValues().get(1).getInterventionId()).isEqualTo(3); + assertThat(interventionCaptor.getAllValues().get(1).getObservationGroupId()).isEqualTo(2); assertThat(idLongCaptor.getAllValues()).allMatch(Predicate.isEqual(1L)); + + assertThat(participantsCaptor.getAllValues().stream().map(Participant::getStudyId)).allMatch(Predicate.isEqual(1L)); + assertThat(participantsCaptor.getAllValues().subList(0,3).stream().map(Participant::getStudyGroupId)).allMatch(Predicate.isEqual(0)); + assertThat(participantsCaptor.getAllValues().subList(3,6).stream().map(Participant::getStudyGroupId)).allMatch(Predicate.isEqual(2)); + assertThat(participantsCaptor.getAllValues().subList(6,8).stream().map(Participant::getStudyGroupId)).allMatch(Predicate.isEqual(4)); + assertThat(participantsCaptor.getAllValues().get(0).getObservationGroupIds()).isEmpty(); + assertThat(participantsCaptor.getAllValues().get(1).getObservationGroupIds()).containsExactlyInAnyOrder(1); + assertThat(participantsCaptor.getAllValues().get(2).getObservationGroupIds()).containsExactlyInAnyOrder(1, 2); + assertThat(participantsCaptor.getAllValues().get(3).getObservationGroupIds()).isEmpty(); + assertThat(participantsCaptor.getAllValues().get(4).getObservationGroupIds()).containsExactlyInAnyOrder(2); + assertThat(participantsCaptor.getAllValues().get(5).getObservationGroupIds()).containsExactlyInAnyOrder(1, 2); + assertThat(participantsCaptor.getAllValues().get(6).getObservationGroupIds()).containsExactlyInAnyOrder(1); + assertThat(participantsCaptor.getAllValues().get(7).getObservationGroupIds()).containsExactlyInAnyOrder(2); } From 702c5972830963808d9db473ae6d5a25647e5105 Mon Sep 17 00:00:00 2001 From: Rupert Westenthaler Date: Wed, 17 Dec 2025 09:50:16 +0100 Subject: [PATCH 3/4] Apply suggestions from code review Prefer use of Collections.empty**() Co-authored-by: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> --- .../more/studymanager/repository/InterventionRepository.java | 2 +- .../controller/studymanager/CalendarApiV1Controller.java | 2 +- .../studymanager/model/transformer/ImportExportTransformer.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java index b6b473a2..7ffdc3d2 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java @@ -88,7 +88,7 @@ public List listInterventions(Long studyId) { } public List listInterventionsForGroup(Long studyId, Integer groupId){ - return listInterventionsForGroup(studyId, groupId, List.of()); + return listInterventionsForGroup(studyId, groupId, Collections.emptyList()); } public List listInterventionsForGroup(Long studyId, Integer groupId, Collection observationGroupIds) { return namedTemplate.query(LIST_INTERVENTIONS_FOR_GROUP, diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java index 552386c7..259ba169 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java @@ -56,7 +56,7 @@ public ResponseEntity getStudyTimeline(Long studyId, Integer p TimelineTransformer.toStudyTimelineDTO( service.getTimeline( studyId, participant, studyGroup, - observationGroup == null ? Set.of() : Set.of(observationGroup), + observationGroup == null ? Collections.emptySet() : Set.of(observationGroup), referenceDate, from, to) ) ); diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ImportExportTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ImportExportTransformer.java index 40030651..9157b017 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ImportExportTransformer.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ImportExportTransformer.java @@ -81,7 +81,7 @@ private static ParticipantInfoDTO toParticipantDTO_V1(StudyImportExport.Particip private static StudyImportExport.ParticipantInfo fromParticipantDTO_V1(ParticipantInfoDTO participant) { return new StudyImportExport.ParticipantInfo( participant.getStudyGroup(), - participant.getObservationGroups() == null ? Set.of() : participant.getObservationGroups()); + participant.getObservationGroups() == null ? Collections.emptySet() : participant.getObservationGroups()); } private static List transform(List list, Function transformer) { From 0251c4b976922e3baea29e0fe0bef1bf4f8f838d Mon Sep 17 00:00:00 2001 From: Rupert Westenthaler Date: Wed, 17 Dec 2025 10:02:24 +0100 Subject: [PATCH 4/4] missing import statements --- .../more/studymanager/repository/InterventionRepository.java | 1 + .../controller/studymanager/CalendarApiV1Controller.java | 1 + 2 files changed, 2 insertions(+) diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java index 7ffdc3d2..ec6001f1 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java @@ -25,6 +25,7 @@ import org.springframework.stereotype.Component; import java.util.Collection; +import java.util.Collections; import java.util.List; import static io.redlink.more.studymanager.repository.RepositoryUtils.getValidNullableIntegerValue; diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java index 259ba169..de657dcf 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java @@ -24,6 +24,7 @@ import java.time.Instant; import java.time.LocalDate; +import java.util.Collections; import java.util.Set; @RestController