diff --git a/Studies/api-src/org/labkey/api/studies/StudiesService.java b/Studies/api-src/org/labkey/api/studies/StudiesService.java index 249137680..6d85d4cc4 100644 --- a/Studies/api-src/org/labkey/api/studies/StudiesService.java +++ b/Studies/api-src/org/labkey/api/studies/StudiesService.java @@ -4,9 +4,11 @@ import org.labkey.api.module.Module; import org.labkey.api.resource.Resource; import org.labkey.api.security.User; +import org.labkey.api.studies.study.EventProvider; import org.labkey.api.util.Path; import java.io.IOException; +import java.util.List; /** * Created by bimber on 11/3/2016. @@ -28,4 +30,8 @@ static public void setInstance(StudiesService instance) abstract public void importFolderDefinition(Container container, User user, Module m, Path sourceFolderDirPath) throws IOException; abstract public void loadTsv(Resource tsv, String schemaName, User u, Container c); + + abstract public void registerEventProvider(EventProvider ep); + + abstract public List getEventProviders(Container c); } diff --git a/Studies/api-src/org/labkey/api/studies/study/AbstractEventProvider.java b/Studies/api-src/org/labkey/api/studies/study/AbstractEventProvider.java new file mode 100644 index 000000000..11eed6e32 --- /dev/null +++ b/Studies/api-src/org/labkey/api/studies/study/AbstractEventProvider.java @@ -0,0 +1,88 @@ +package org.labkey.api.studies.study; + +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.api.data.TableInfo; +import org.labkey.api.module.Module; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.UserSchema; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.ReadPermission; + +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +public abstract class AbstractEventProvider implements EventProvider +{ + private final String _name; + private final String _label; + private final String _description; + private final Module _owner; + + public AbstractEventProvider(String name, String label, String description, Module owner) + { + _name = name; + _label = label; + _description = description; + _owner = owner; + } + + @Override + public String getDescription() + { + return _description; + } + + @Override + public String getLabel() + { + return _label; + } + + @Override + public String getName() + { + return _name; + } + + @Override + public boolean isAvailable(Container c) + { + return c.getActiveModules().contains(_owner); + } + + @Override + public final Map inferDates(Collection subjectList, Container c, User u) + { + Map result = new HashMap<>(inferDatesRaw(subjectList, c, u)); + subjectList.forEach(x -> { + if (!result.containsKey(x)) + { + result.put(x, null); + } + }); + + return result; + } + + abstract protected Map inferDatesRaw(Collection subjectList, Container c, User u); + + protected @Nullable TableInfo getTable(Container c, User u, String schema, String table) + { + UserSchema us = QueryService.get().getUserSchema(u, c, schema); + if (us == null) + { + return null; + } + + TableInfo ti = us.getTable("assignment"); + if (ti == null || !ti.hasPermission(u, ReadPermission.class)) + { + return null; + } + + return ti; + } +} diff --git a/Studies/api-src/org/labkey/api/studies/study/EventProvider.java b/Studies/api-src/org/labkey/api/studies/study/EventProvider.java new file mode 100644 index 000000000..39690c242 --- /dev/null +++ b/Studies/api-src/org/labkey/api/studies/study/EventProvider.java @@ -0,0 +1,26 @@ +package org.labkey.api.studies.study; + +import org.labkey.api.data.Container; +import org.labkey.api.security.User; + +import java.util.Collection; +import java.util.Date; +import java.util.Map; + +/** + * Each study will have a handful of important dates, which are used to define relative dates for each subject/participant. + * The EventProvider classes provide a code-based way to establish the handful of critical dates. This code is executed to populate + * the KeyEvents table, which maps subject/event to date. + */ +public interface EventProvider +{ + boolean isAvailable(Container c); + + String getName(); + + String getLabel(); + + String getDescription(); + + Map inferDates(Collection subjectList, Container c, User u); +} diff --git a/Studies/resources/schemas/dbscripts/postgresql/studies-23.001-23.002.sql b/Studies/resources/schemas/dbscripts/postgresql/studies-23.001-23.002.sql new file mode 100644 index 000000000..7fb2a0551 --- /dev/null +++ b/Studies/resources/schemas/dbscripts/postgresql/studies-23.001-23.002.sql @@ -0,0 +1,90 @@ +CREATE TABLE studies.studies ( + rowid serial, + studyName varchar(1000), + label varchar(1000), + category varchar(1000), + description varchar(4000), + + lsid entityid, + container entityid, + created timestamp, + createdby int, + modified timestamp, + modifiedby int, + + CONSTRAINT PK_studies PRIMARY KEY (rowid) +); + +CREATE TABLE studies.studyCohorts ( + rowid serial, + studyId int, + cohortName varchar(4000), + label varchar(4000), + category varchar(4000), + description varchar(4000), + isControlGroup bool default false, + sortOrder int, + + lsid entityid, + container entityid, + created timestamp, + createdby int, + modified timestamp, + modifiedby int, + + CONSTRAINT PK_studyCohorts PRIMARY KEY (rowid) +); + +CREATE TABLE studies.anchorEvents ( + rowid serial, + studyId int, + label varchar(4000), + description varchar(4000), + eventProviderName varchar(1000), + + container entityid, + created timestamp, + createdby int, + modified timestamp, + modifiedby int, + + CONSTRAINT PK_anchorEvents PRIMARY KEY (rowid) +); + +CREATE TABLE studies.expectedTimepoints ( + rowid serial, + studyId int, + cohortId int, + label varchar(4000), + labelShort varchar(100), + description varchar(4000), + numericLabel int, + anchorEvent int, + rangeMin int, + rangeMax int, + + container entityid, + created timestamp, + createdby int, + modified timestamp, + modifiedby int, + + CONSTRAINT PK_expectedTimepoints PRIMARY KEY (rowid) +); + +CREATE TABLE studies.timepointToDate ( + rowid serial, + subjectId varchar(4000), + timepointId int, + dateMin timestamp, + dateMax timestamp, + isManualOverride bool default false, + + container entityid, + created timestamp, + createdby int, + modified timestamp, + modifiedby int, + + CONSTRAINT PK_timepointToDate PRIMARY KEY (rowid) +); \ No newline at end of file diff --git a/Studies/resources/schemas/dbscripts/sqlserver/studies-23.001-23.002.sql b/Studies/resources/schemas/dbscripts/sqlserver/studies-23.001-23.002.sql new file mode 100644 index 000000000..5d7d6b54d --- /dev/null +++ b/Studies/resources/schemas/dbscripts/sqlserver/studies-23.001-23.002.sql @@ -0,0 +1,90 @@ +CREATE TABLE studies.studies ( + rowid int identity(1,1), + studyName varchar(1000), + label varchar(1000), + category varchar(1000), + description varchar(4000), + + lsid entityid, + container entityid, + created datetime, + createdby int, + modified datetime, + modifiedby int, + + CONSTRAINT PK_studies PRIMARY KEY (rowid) +); + +CREATE TABLE studies.studyCohorts ( + rowid int identity(1,1), + studyId int, + cohortName varchar(4000), + label varchar(4000), + category varchar(4000), + description varchar(4000), + isControlGroup bit default 0, + sortOrder int, + + lsid entityid, + container entityid, + created datetime, + createdby int, + modified datetime, + modifiedby int, + + CONSTRAINT PK_studyCohorts PRIMARY KEY (rowid) +); + +CREATE TABLE studies.anchorEvents ( + rowid int identity(1,1), + studyId int, + label varchar(4000), + description varchar(4000), + eventProviderName varchar(1000), + + container entityid, + created datetime, + createdby int, + modified datetime, + modifiedby int, + + CONSTRAINT PK_anchorEvents PRIMARY KEY (rowid) +); + +CREATE TABLE studies.expectedTimepoints ( + rowid int identity(1,1), + studyId int, + cohortId int, + label varchar(4000), + labelShort varchar(100), + description varchar(4000), + numericLabel int, + anchorEvent int, + rangeMin int, + rangeMax int, + + container entityid, + created datetime, + createdby int, + modified datetime, + modifiedby int, + + CONSTRAINT PK_expectedTimepoints PRIMARY KEY (rowid) +); + +CREATE TABLE studies.timepointToDate ( + rowid int identity(1,1), + subjectId varchar(4000), + timepointId int, + dateMin datetime, + dateMax datetime, + isManualOverride bit default 0, + + container entityid, + created datetime, + createdby int, + modified datetime, + modifiedby int, + + CONSTRAINT PK_timepointToDate PRIMARY KEY (rowid) +); \ No newline at end of file diff --git a/Studies/resources/schemas/studies.xml b/Studies/resources/schemas/studies.xml index 054cd9393..a5c389963 100644 --- a/Studies/resources/schemas/studies.xml +++ b/Studies/resources/schemas/studies.xml @@ -124,4 +124,375 @@ + + + Studies + DETAILED + + + + + + + true + false + false + true + + + Study Name + + + Label + + + Category + + + Description + textarea + + + lsidtype + true + true + false + + ObjectUri + Object + exp + + + + false + true + true + + + false + true + true + + + false + true + true + + + false + true + true + + + false + true + true + + +
+ + + Study Cohorts + DETAILED + + + + + + + true + false + false + true + + + Study ID + + studies + studies + rowId + label + + + + Cohort Name + + + Label + + + Category + + + Description + textarea + + + Is Control Group? + + + Sort Order + + + lsidtype + true + true + false + + ObjectUri + Object + exp + + + + false + true + true + + + false + true + true + + + false + true + true + + + false + true + true + + + false + true + true + + +
+ + + Study Anchor Events + This table contains the key event types that anchor relative dates in this study + DETAILED + + + + + + + true + false + false + true + + + Study ID + + studies + studies + rowId + label + + + + Label + + + Description + textarea + + + Event Provider + + studies + studyEventTypes + name + label + + + + false + true + true + + + false + true + true + + + false + true + true + + + false + true + true + + + false + true + true + + +
+ + + Study Expected Timepoints + This table contains the expected timepoints within this study, by cohort + DETAILED + + + + + + + true + false + false + true + + + Study ID + + studies + studies + rowId + label + + + + Cohort ID + + studies + studyCohorts + rowId + label + + + + Label + + + Short Label + + + Description + textarea + + + Numeric Label + + + Anchor Event + + studies + anchorEvents + rowId + label + + + + Min Days Relative to Anchor + + + Max Days Relative to Anchor + + + + false + true + true + + + false + true + true + + + false + true + true + + + false + true + true + + + false + true + true + + +
+ + + Timepoint to Date Range + Per subject, this table contains the allowable date ranges to map record to timepoint label. It is usually populated automatically, but can be manually overridden + DETAILED + + + + + + + true + false + false + true + + + Subject Id + http://cpas.labkey.com/Study#ParticipantId + + + Timepoint + + studies + expectedTimepoints + rowId + label + + + + Min Allowable Date + + + Max Allowable Date + + + Is Manual Override? + + + + false + true + true + + + false + true + true + + + false + true + true + + + false + true + true + + + false + true + true + + +
\ No newline at end of file diff --git a/Studies/resources/views/manageStudy.html b/Studies/resources/views/manageStudy.html new file mode 100644 index 000000000..d00c85923 --- /dev/null +++ b/Studies/resources/views/manageStudy.html @@ -0,0 +1 @@ +PLACEHOLDER: Make a page that accepts a studyId and renders useful UI to manage timepoints, run QC, and show data diff --git a/Studies/resources/views/studiesDetails.html b/Studies/resources/views/studiesDetails.html new file mode 100644 index 000000000..861748943 --- /dev/null +++ b/Studies/resources/views/studiesDetails.html @@ -0,0 +1,9 @@ +PLACEHOLDER: make a page that accepts a studyId and renders useful detail, including: + +studies.studies +studies.studyCohorts +studies.expectedTimepoints + +summary of data, including total subjects, links to datasets + +links to show subject/timepoint mapping. Maybe link to a QC report. diff --git a/Studies/resources/views/studiesOverview.html b/Studies/resources/views/studiesOverview.html new file mode 100644 index 000000000..51ba744ae --- /dev/null +++ b/Studies/resources/views/studiesOverview.html @@ -0,0 +1,5 @@ +Hello! + +TODO: +- Query list of studies, perhaps tile/card view? +- Query list of datasets diff --git a/Studies/resources/views/studiesOverview.view.xml b/Studies/resources/views/studiesOverview.view.xml new file mode 100644 index 000000000..7e678692d --- /dev/null +++ b/Studies/resources/views/studiesOverview.view.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Studies/resources/views/studiesOverview.webpart.xml b/Studies/resources/views/studiesOverview.webpart.xml new file mode 100644 index 000000000..8821d658e --- /dev/null +++ b/Studies/resources/views/studiesOverview.webpart.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Studies/src/org/labkey/studies/StudiesModule.java b/Studies/src/org/labkey/studies/StudiesModule.java index abb00167c..eb2d32cd4 100644 --- a/Studies/src/org/labkey/studies/StudiesModule.java +++ b/Studies/src/org/labkey/studies/StudiesModule.java @@ -12,6 +12,7 @@ import org.labkey.api.studies.StudiesService; import org.labkey.studies.query.StudiesUserSchema; import org.labkey.api.studies.security.StudiesDataAdminRole; +import org.labkey.studies.study.StudyEnrollmentEventProvider; import java.util.Collection; import java.util.Collections; @@ -30,7 +31,7 @@ public String getName() @Override public @Nullable Double getSchemaVersion() { - return 23.001; + return 23.002; } @Override @@ -39,6 +40,7 @@ protected void init() addController(StudiesController.NAME, StudiesController.class); StudiesService.setInstance(StudiesServiceImpl.get()); + StudiesService.get().registerEventProvider(new StudyEnrollmentEventProvider()); RoleManager.registerRole(new StudiesDataAdminRole()); } diff --git a/Studies/src/org/labkey/studies/StudiesServiceImpl.java b/Studies/src/org/labkey/studies/StudiesServiceImpl.java index 594ff3dfb..73ebb28b9 100644 --- a/Studies/src/org/labkey/studies/StudiesServiceImpl.java +++ b/Studies/src/org/labkey/studies/StudiesServiceImpl.java @@ -18,6 +18,8 @@ import org.labkey.api.resource.Resource; import org.labkey.api.security.User; import org.labkey.api.studies.StudiesService; +import org.labkey.api.studies.study.EventProvider; +import org.labkey.api.util.ConfigurationException; import org.labkey.api.util.FileUtil; import org.labkey.api.util.Path; import org.labkey.api.util.logging.LogHelper; @@ -28,6 +30,7 @@ import java.io.OutputStream; import java.nio.file.Files; import java.sql.SQLException; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -135,4 +138,23 @@ public void loadTsv(Resource tsv, String schemaName, User u, Container c) throw new RuntimeException(e); } } + + private final Map _eventProviders = new HashMap<>(); + + @Override + public void registerEventProvider(EventProvider ep) + { + if (_eventProviders.containsKey(ep.getName())) + { + throw new ConfigurationException("There is already a provider registered with the name: " + ep.getName()); + } + + _eventProviders.put(ep.getName(), ep); + } + + @Override + public List getEventProviders(Container c) + { + return _eventProviders.values().stream().filter(ep -> ep.isAvailable(c)).toList(); + } } diff --git a/Studies/src/org/labkey/studies/query/StudiesUserSchema.java b/Studies/src/org/labkey/studies/query/StudiesUserSchema.java index 2fb451c26..6713a5e47 100644 --- a/Studies/src/org/labkey/studies/query/StudiesUserSchema.java +++ b/Studies/src/org/labkey/studies/query/StudiesUserSchema.java @@ -1,7 +1,9 @@ package org.labkey.studies.query; +import org.apache.logging.log4j.Logger; import org.labkey.api.collections.CaseInsensitiveHashMap; import org.labkey.api.collections.CaseInsensitiveTreeSet; +import org.labkey.api.data.AbstractTableInfo; import org.labkey.api.data.Container; import org.labkey.api.data.ContainerFilter; import org.labkey.api.data.DbSchema; @@ -9,19 +11,27 @@ import org.labkey.api.data.SimpleFilter; import org.labkey.api.data.TableInfo; import org.labkey.api.data.TableSelector; +import org.labkey.api.data.dialect.SqlDialect; import org.labkey.api.ldk.table.ContainerScopedTable; import org.labkey.api.ldk.table.CustomPermissionsTable; import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryDefinition; +import org.labkey.api.query.QueryException; +import org.labkey.api.query.QueryService; import org.labkey.api.query.SimpleUserSchema; import org.labkey.api.security.User; import org.labkey.api.security.permissions.DeletePermission; import org.labkey.api.security.permissions.InsertPermission; import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.security.permissions.UpdatePermission; -import org.labkey.studies.StudiesSchema; +import org.labkey.api.studies.StudiesService; import org.labkey.api.studies.security.StudiesDataAdminPermission; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.studies.StudiesSchema; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Set; @@ -30,6 +40,9 @@ public class StudiesUserSchema extends SimpleUserSchema { + private static final Logger _log = LogHelper.getLogger(StudiesUserSchema.class, "Messages related to Studies Service"); + private static final String TABLE_EVENT_TYPES = "studyEventTypes"; + public StudiesUserSchema(User user, Container container, DbSchema dbschema) { super(StudiesSchema.NAME, "", user, container, dbschema); @@ -39,6 +52,7 @@ public StudiesUserSchema(User user, Container container, DbSchema dbschema) public Set getTableNames() { Set available = new CaseInsensitiveTreeSet(super.getTableNames()); + available.add(TABLE_EVENT_TYPES); available.addAll(getPropertySetNames().keySet()); return Collections.unmodifiableSet(available); @@ -104,6 +118,10 @@ else if (TABLE_LOOKUPS.equalsIgnoreCase(name)) ret.addPermissionMapping(ReadPermission.class, StudiesDataAdminPermission.class); return ret.init(); } + else if (TABLE_EVENT_TYPES.equalsIgnoreCase(name)) + { + return createEventTypesTable(getContainer()); + } //try to find it in propertySets Map> nameMap = getPropertySetNames(); @@ -122,4 +140,47 @@ private LookupSetTable createForPropertySet(StudiesUserSchema us, ContainerFilte ret.addPermissionMapping(DeletePermission.class, StudiesDataAdminPermission.class); return ret.init(); } + + private TableInfo createEventTypesTable(Container container) + { + StringBuilder sql = new StringBuilder("SELECT * FROM ("); + final int startLength = sql.length(); + StudiesService.get().getEventProviders(container).forEach(ep -> { + if (sql.length() > startLength) + { + sql.append("UNION ALL\n"); + } + + sql.append("SELECT "). + append("'").append(ep.getName()).append("' AS name, "). + append("'").append(ep.getLabel()).append("' AS label, "). + append("'").append(ep.getDescription()).append("' AS description\n"); + }); + + sql.append(") x"); + + QueryDefinition qd = QueryService.get().createQueryDef(getUser(), getContainer(), this, TABLE_EVENT_TYPES); + qd.setSql(sql.toString()); + + List errors = new ArrayList<>(); + TableInfo ti = qd.getTable(errors, true); + if (!errors.isEmpty()) + { + _log.error("Problem with studyEventTypes query"); + for (QueryException e : errors) + { + _log.error(e.getMessage()); + } + } + + if (ti instanceof AbstractTableInfo ati) + { + ati.setTitle("Study Event Types"); + ati.getMutableColumn("name").setLabel("Name"); + ati.getMutableColumn("label").setLabel("Label"); + ati.getMutableColumn("description").setLabel("Description"); + } + + return ti; + } } diff --git a/Studies/src/org/labkey/studies/study/StudyEnrollmentEventProvider.java b/Studies/src/org/labkey/studies/study/StudyEnrollmentEventProvider.java new file mode 100644 index 000000000..9682541ef --- /dev/null +++ b/Studies/src/org/labkey/studies/study/StudyEnrollmentEventProvider.java @@ -0,0 +1,59 @@ +package org.labkey.studies.study; + +import org.labkey.api.data.CompareType; +import org.labkey.api.data.Container; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.module.ModuleLoader; +import org.labkey.api.query.FieldKey; +import org.labkey.api.security.User; +import org.labkey.api.studies.study.AbstractEventProvider; +import org.labkey.api.study.DatasetTable; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.studies.StudiesModule; + +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +public class StudyEnrollmentEventProvider extends AbstractEventProvider +{ + public StudyEnrollmentEventProvider() + { + super("EnrollmentStart", "Enrollment Start", "This is the first date when the subject was assigned to the study, as defined in the study assignment table", ModuleLoader.getInstance().getModule(StudiesModule.class)); + } + + @Override + protected Map inferDatesRaw(Collection subjectList, Container c, User u) + { + TableInfo ti = getTable(c, u, "study", "assignment"); + if (ti == null) + { + return Collections.emptyMap(); + } + + if (ti instanceof DatasetTable ds) + { + Map ret = new HashMap<>(); + final String subjectCol = ds.getDataset().getStudy().getSubjectColumnName(); + new TableSelector(ti, PageFlowUtil.set(subjectCol, "date"), new SimpleFilter(FieldKey.fromString(subjectCol), subjectList, CompareType.IN), null).forEachResults(rs -> { + String subjectId = rs.getString(FieldKey.fromString(subjectCol)); + Date date = rs.getDate(FieldKey.fromString("date")); + + if (!ret.containsKey(subjectId) || date.before(ret.get(subjectId))) + { + ret.put(subjectId, date); + } + }); + + return ret; + } + else + { + throw new IllegalStateException("Expected study.assignment to be a DatasetTable"); + } + } +}