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/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/repository/InterventionRepository.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionRepository.java index 03788160..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 @@ -24,6 +24,8 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; 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; @@ -31,14 +33,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 +62,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 +78,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 +88,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, Collections.emptyList()); + } + 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 +197,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 +243,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/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/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/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/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-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/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/CalendarApiV1Controller.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/CalendarApiV1Controller.java index 498a1e2a..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,8 @@ import java.time.Instant; import java.time.LocalDate; +import java.util.Collections; +import java.util.Set; @RestController @RequestMapping(value = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE) @@ -50,10 +52,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 ? Collections.emptySet() : Set.of(observationGroup), + referenceDate, from, to) ) ); } 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/ImportExportTransformer.java b/studymanager/src/main/java/io/redlink/more/studymanager/model/transformer/ImportExportTransformer.java index a4979f67..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 @@ -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 ? Collections.emptySet() : participant.getObservationGroups()); } private static List transform(List list, Function transformer) { 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/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 471246cd..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 @@ -476,6 +480,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 +1723,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 +1781,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 +1850,8 @@ components: noSchedule: type: boolean default: false + observationGroupId: + $ref: '#/components/schemas/IdReference' StudyTimeline: @@ -1913,6 +2051,8 @@ components: type: array items: $ref: '#/components/schemas/Action' + observationGroupId: + $ref: '#/components/schemas/IdReference' created: type: string format: date-time @@ -1982,6 +2122,10 @@ components: type: array items: $ref: '#/components/schemas/StudyGroup' + observationGroups: + type: array + items: + $ref: '#/components/schemas/ObservationGroup' observations: type: array items: @@ -2004,6 +2148,11 @@ components: properties: studyGroup: type: integer + observationGroups: + type: array + items: + type: integer + uniqueItems: true IntegrationInfo: type: object @@ -2364,6 +2513,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/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/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..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 @@ -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,11 @@ 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()) + //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); }