From 2a8e7d511f8eb33b6c3dbd4dbc1b034fc6cd017b Mon Sep 17 00:00:00 2001 From: vagisha Date: Tue, 9 Sep 2025 14:29:26 -0700 Subject: [PATCH] Send reminders to submitters of private data on Panorama Public (#566) - Send automated reminders to submitters of private datasets. - Reminders can be enabled, and properties such as "delay until first reminder", "reminder frequency" and "extension duration" can be configured via the Panorama Public Admin Console, - The Admin Console also provides an option to send reminders manually. - Submitters can request an extension of the private data status or request deletion of their data from Panorama Public. - Links to request extension or deletion are included in the reminder message. - Extension and deletion requests by the submitter are posted to the data message thread for record keeping. - Escape tilde (~) when generating notification messages. In the LabKey Markdown flavor, text between single tildes as well as double tildes is rendered as strikethrough. --- .../panoramapublic-25.001-25.002.sql | 21 + .../panoramapublic-25.002-25.003.sql | 32 + .../resources/schemas/panoramapublic.xml | 34 + .../PanoramaPublicController.java | 549 ++++++++++++++- .../panoramapublic/PanoramaPublicManager.java | 5 + .../panoramapublic/PanoramaPublicModule.java | 4 +- .../PanoramaPublicNotification.java | 157 ++++- .../panoramapublic/PanoramaPublicSchema.java | 8 + .../message/PrivateDataMessageScheduler.java | 128 ++++ .../message/PrivateDataReminderSettings.java | 421 ++++++++++++ .../panoramapublic/model/DatasetStatus.java | 75 +++ .../pipeline/CopyExperimentFinalTask.java | 1 + .../pipeline/PrivateDataReminderJob.java | 627 ++++++++++++++++++ .../query/DatasetStatusManager.java | 52 ++ .../query/DatasetStatusTableInfo.java | 36 + .../query/ExperimentAnnotationsManager.java | 3 + .../query/ExperimentAnnotationsTableInfo.java | 91 ++- .../panoramapublic/view/createMessageForm.jsp | 2 +- .../view/privateDataRemindersSettingsForm.jsp | 154 +++++ .../view/sendPrivateDataRemindersForm.jsp | 78 +++ .../PanoramaPublicBaseTest.java | 16 + .../PanoramaPublicMakePublicTest.java | 17 - .../PrivateDataReminderTest.java | 453 +++++++++++++ 23 files changed, 2932 insertions(+), 32 deletions(-) create mode 100644 panoramapublic/resources/schemas/dbscripts/postgresql/panoramapublic-25.001-25.002.sql create mode 100644 panoramapublic/resources/schemas/dbscripts/postgresql/panoramapublic-25.002-25.003.sql create mode 100644 panoramapublic/src/org/labkey/panoramapublic/message/PrivateDataMessageScheduler.java create mode 100644 panoramapublic/src/org/labkey/panoramapublic/message/PrivateDataReminderSettings.java create mode 100644 panoramapublic/src/org/labkey/panoramapublic/model/DatasetStatus.java create mode 100644 panoramapublic/src/org/labkey/panoramapublic/pipeline/PrivateDataReminderJob.java create mode 100644 panoramapublic/src/org/labkey/panoramapublic/query/DatasetStatusManager.java create mode 100644 panoramapublic/src/org/labkey/panoramapublic/query/DatasetStatusTableInfo.java create mode 100644 panoramapublic/src/org/labkey/panoramapublic/view/privateDataRemindersSettingsForm.jsp create mode 100644 panoramapublic/src/org/labkey/panoramapublic/view/sendPrivateDataRemindersForm.jsp create mode 100644 panoramapublic/test/src/org/labkey/test/tests/panoramapublic/PrivateDataReminderTest.java diff --git a/panoramapublic/resources/schemas/dbscripts/postgresql/panoramapublic-25.001-25.002.sql b/panoramapublic/resources/schemas/dbscripts/postgresql/panoramapublic-25.001-25.002.sql new file mode 100644 index 00000000..e22e7021 --- /dev/null +++ b/panoramapublic/resources/schemas/dbscripts/postgresql/panoramapublic-25.001-25.002.sql @@ -0,0 +1,21 @@ +CREATE TABLE panoramapublic.DatasetStatus +( + _ts TIMESTAMP, + Id SERIAL NOT NULL, + CreatedBy USERID, + Created TIMESTAMP, + ModifiedBy USERID, + Modified TIMESTAMP, + + ShortUrl ENTITYID NOT NULL, + LastReminderDate TIMESTAMP, + ExtensionRequestedDate TIMESTAMP, + DeletionRequestedDate TIMESTAMP, + + CONSTRAINT PK_DatasetStatus PRIMARY KEY (Id), + + CONSTRAINT FK_DatasetStatus_ShortUrl FOREIGN KEY (ShortUrl) REFERENCES core.shorturl (entityId), + + CONSTRAINT UQ_DatasetStatus_ShortUrl UNIQUE (ShortUrl) +); +CREATE INDEX IX_DatasetStatus_ShortUrl ON panoramapublic.DatasetStatus(ShortUrl); diff --git a/panoramapublic/resources/schemas/dbscripts/postgresql/panoramapublic-25.002-25.003.sql b/panoramapublic/resources/schemas/dbscripts/postgresql/panoramapublic-25.002-25.003.sql new file mode 100644 index 00000000..81bf665f --- /dev/null +++ b/panoramapublic/resources/schemas/dbscripts/postgresql/panoramapublic-25.002-25.003.sql @@ -0,0 +1,32 @@ +-- ------------------------------------------------------------------------ +-- Replace the ShortUrl column with an ExperimentAnnotationsId column. +-- ------------------------------------------------------------------------ + +-- Add ExperimentAnnotationsId column as nullable first +ALTER TABLE panoramapublic.DatasetStatus ADD COLUMN ExperimentAnnotationsId INT; + +-- Populate the column by matching ShortUrl values +UPDATE panoramapublic.DatasetStatus +SET ExperimentAnnotationsId = ea.Id +FROM panoramapublic.ExperimentAnnotations ea +WHERE ea.ShortUrl = panoramapublic.DatasetStatus.ShortUrl; + +-- Delete rows that couldn't be matched (where ExperimentAnnotationsId is still null) +DELETE FROM panoramapublic.DatasetStatus WHERE ExperimentAnnotationsId IS NULL; + +-- Now make ExperimentAnnotationsId NOT NULL +ALTER TABLE panoramapublic.DatasetStatus ALTER COLUMN ExperimentAnnotationsId SET NOT NULL; + +-- Add constraints and index +ALTER TABLE panoramapublic.DatasetStatus ADD CONSTRAINT FK_DatasetStatus_ExperimentAnnotations FOREIGN KEY (ExperimentAnnotationsId) REFERENCES panoramapublic.ExperimentAnnotations(Id); +ALTER TABLE panoramapublic.DatasetStatus ADD CONSTRAINT UQ_DatasetStatus_ExperimentAnnotations UNIQUE (ExperimentAnnotationsId); +CREATE INDEX IX_DatasetStatus_ExperimentAnnotations ON panoramapublic.DatasetStatus(ExperimentAnnotationsId); + +-- Drop old constraints and index +ALTER TABLE panoramapublic.DatasetStatus DROP CONSTRAINT FK_DatasetStatus_ShortUrl; +ALTER TABLE panoramapublic.DatasetStatus DROP CONSTRAINT UQ_DatasetStatus_ShortUrl; +DROP INDEX panoramapublic.IX_DatasetStatus_ShortUrl; + +-- Finally drop the ShortUrl column +ALTER TABLE panoramapublic.DatasetStatus DROP COLUMN ShortUrl; + diff --git a/panoramapublic/resources/schemas/panoramapublic.xml b/panoramapublic/resources/schemas/panoramapublic.xml index 70a96e08..6b3acf79 100644 --- a/panoramapublic/resources/schemas/panoramapublic.xml +++ b/panoramapublic/resources/schemas/panoramapublic.xml @@ -832,4 +832,38 @@ + + + + true + + + true + + + + UserId + core + UsersData + + true + + + true + + + + UserId + core + UsersData + + true + + + + + + + +
\ No newline at end of file diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java index 9936aacd..fe84ac06 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicController.java @@ -124,6 +124,7 @@ import org.labkey.api.targetedms.TargetedMSUrls; import org.labkey.api.util.ButtonBuilder; import org.labkey.api.util.DOM; +import org.labkey.api.util.DateUtil; import org.labkey.api.util.ExceptionUtil; import org.labkey.api.util.FileUtil; import org.labkey.api.util.HtmlString; @@ -152,8 +153,11 @@ import org.labkey.panoramapublic.datacite.DataCiteService; import org.labkey.panoramapublic.datacite.Doi; import org.labkey.panoramapublic.datacite.DoiMetadata; +import org.labkey.panoramapublic.message.PrivateDataMessageScheduler; +import org.labkey.panoramapublic.message.PrivateDataReminderSettings; import org.labkey.panoramapublic.model.CatalogEntry; import org.labkey.panoramapublic.model.DataLicense; +import org.labkey.panoramapublic.model.DatasetStatus; import org.labkey.panoramapublic.model.ExperimentAnnotations; import org.labkey.panoramapublic.model.Journal; import org.labkey.panoramapublic.model.JournalExperiment; @@ -169,6 +173,7 @@ import org.labkey.panoramapublic.model.validation.Status; import org.labkey.panoramapublic.pipeline.CopyExperimentPipelineJob; import org.labkey.panoramapublic.pipeline.PostPanoramaPublicMessageJob; +import org.labkey.panoramapublic.pipeline.PrivateDataReminderJob; import org.labkey.panoramapublic.pipeline.PxDataValidationPipelineJob; import org.labkey.panoramapublic.pipeline.PxValidationPipelineProvider; import org.labkey.panoramapublic.proteomexchange.ChemElement; @@ -188,6 +193,7 @@ import org.labkey.panoramapublic.query.CatalogEntryManager.CatalogEntryType; import org.labkey.panoramapublic.query.DataValidationManager; import org.labkey.panoramapublic.query.DataValidationManager.MissingMetadata; +import org.labkey.panoramapublic.query.DatasetStatusManager; import org.labkey.panoramapublic.query.ExperimentAnnotationsManager; import org.labkey.panoramapublic.query.JournalManager; import org.labkey.panoramapublic.query.ModificationInfoManager; @@ -225,6 +231,7 @@ import java.util.Arrays; import java.util.Base64; import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -313,6 +320,7 @@ public ModelAndView getView(Object o, BindException errors) view.addView(getDataCiteCredentialsLink()); view.addView(getBlueskySettingsLink()); view.addView(getPanoramaPublicCatalogSettingsLink()); + view.addView(getPrivateDataReminderSettingsLink()); view.addView(getPostSupportMessageLink()); view.setFrame(WebPartView.FrameType.PORTAL); view.setTitle("Panorama Public Settings"); @@ -369,10 +377,19 @@ private ModelAndView getPostSupportMessageLink() return null; } + private ModelAndView getPrivateDataReminderSettingsLink() + { + ActionURL url = new ActionURL(PrivateDataReminderSettingsAction.class, getContainer()); + return new HtmlView(DIV( + at(style, "margin-top:20px;"), + LinkBuilder.labkeyLink("Private Data Reminder Settings", url) + )); + } + @Override public void addNavTrail(NavTree root) { - PageFlowUtil.urlProvider(AdminUrls.class).addAdminNavTrail(root, "Panorama Public Admin Console", getClass(), getContainer()); + addPanoramaPublicAdminConsoleNav(root, getContainer()); } } @@ -509,7 +526,7 @@ public void addNavTrail(NavTree root) private static void addPanoramaPublicAdminConsoleNav(NavTree root, Container container) { - root.addChild("Panorama Public Admin Console", new ActionURL(PanoramaPublicAdminViewAction.class, container)); + PageFlowUtil.urlProvider(AdminUrls.class).addAdminNavTrail(root, "Panorama Public Admin Console", PanoramaPublicAdminViewAction.class, container); } public static class CreateJournalGroupForm @@ -1557,6 +1574,7 @@ public ModelAndView getSuccessView(ManageCatalogEntryForm form) @Override public void addNavTrail(NavTree root) { + addPanoramaPublicAdminConsoleNav(root, getContainer()); root.addChild("Panorama Public Catalog Settings"); } } @@ -6298,7 +6316,7 @@ public void addNavTrail(NavTree root) List publishedVersions = ExperimentAnnotationsManager.getPublishedVersionsOfExperiment(sourceExperimentId); if (!publishedVersions.isEmpty()) { - QuerySettings qSettings = new QuerySettings(getViewContext(), "PublishedVersions", "ExperimentAnnotations"); + QuerySettings qSettings = new QuerySettings(getViewContext(), "PublishedVersions", PanoramaPublicSchema.TABLE_EXPERIMENT_ANNOTATIONS); qSettings.setBaseFilter(new SimpleFilter(new SimpleFilter(FieldKey.fromParts("SourceExperimentId"), sourceExperimentId))); List columns = new ArrayList<>(List.of(FieldKey.fromParts("Version"), FieldKey.fromParts("Created"), FieldKey.fromParts("Link"), FieldKey.fromParts("Share"))); @@ -9741,7 +9759,8 @@ public static class CreatePanoramaPublicMessageAction extends SimpleViewAction

+ { + @Override + public void validateCommand(PrivateDataReminderSettingsForm form, Errors errors) + { + if (form.getDelayUntilFirstReminder() == null) + { + errors.reject(ERROR_MSG, "Please enter a value for 'Delay until first reminder'."); + } + else if (form.getDelayUntilFirstReminder() < 0) + { + errors.reject(ERROR_MSG, "Value for 'Delay until first reminder' cannot be less than 0."); + } + if (form.getReminderFrequency() == null) + { + errors.reject(ERROR_MSG, "Please enter a value for 'Reminder frequency'."); + } + else if (form.getReminderFrequency() < 0) + { + errors.reject(ERROR_MSG, "Value for 'Reminder frequency' cannot be less than 0."); + } + if (form.getExtensionLength() == null) + { + errors.reject(ERROR_MSG, "Please enter a value for 'Extension duration'."); + } + else if (form.getExtensionLength() < 0) + { + errors.reject(ERROR_MSG, "Value for 'Extension duration' cannot be less than 0."); + } + if (form.getReminderTime() == null) + { + errors.reject(ERROR_MSG, "Please enter a value for 'Reminder time'."); + } + else if (PrivateDataReminderSettings.parseReminderTime(form.getReminderTime()) == null) + { + errors.reject(ERROR_MSG, String.format("'Reminder time' could not be parsed. It must be in the format - %s, e.g. %s.", + PrivateDataReminderSettings.REMINDER_TIME_FORMAT, PrivateDataReminderSettings.DEFAULT_REMINDER_TIME)); + } + } + + @Override + public ModelAndView getView(PrivateDataReminderSettingsForm form, boolean reshow, BindException errors) throws Exception + { + if (!reshow) + { + PrivateDataReminderSettings settings = PrivateDataReminderSettings.get(); + form.setEnabled(settings.isEnableReminders()); + form.setReminderTime(settings.getReminderTimeFormatted()); + form.setDelayUntilFirstReminder(settings.getDelayUntilFirstReminder()); + form.setReminderFrequency(settings.getReminderFrequency()); + form.setExtensionLength(settings.getExtensionLength()); + } + + VBox view = new VBox(); + view.addView(new JspView<>("/org/labkey/panoramapublic/view/privateDataRemindersSettingsForm.jsp", form, errors)); + view.setTitle("Private Data Reminder Settings"); + view.setFrame(WebPartView.FrameType.PORTAL); + return view; + } + + @Override + public boolean handlePost(PrivateDataReminderSettingsForm form, BindException errors) throws Exception + { + PrivateDataReminderSettings settings = new PrivateDataReminderSettings(); + settings.setEnableReminders(form.isEnabled()); + settings.setReminderTime(PrivateDataReminderSettings.parseReminderTime(form.getReminderTime())); + settings.setDelayUntilFirstReminder(form.getDelayUntilFirstReminder()); + settings.setReminderFrequency(form.getReminderFrequency()); + settings.setExtensionLength(form.getExtensionLength()); + PrivateDataReminderSettings.save(settings); + + PrivateDataMessageScheduler.getInstance().initialize(settings.isEnableReminders()); + return true; + } + + @Override + public URLHelper getSuccessURL(PrivateDataReminderSettingsForm privateDataReminderSettingsForm) + { + return null; + } + + @Override + public ModelAndView getSuccessView(PrivateDataReminderSettingsForm form) + { + ActionURL url = new ActionURL(PrivateDataReminderSettingsAction.class, getContainer()); + return new HtmlView( + DIV("Private data reminder settings saved!", + BR(), + new LinkBuilder("Back to Private Data Reminder Settings").href(url).build())); + } + + @Override + public void addNavTrail(NavTree root) + { + addPanoramaPublicAdminConsoleNav(root, getContainer()); + root.addChild("Private Data Reminder Settings"); + } + } + + public static class PrivateDataReminderSettingsForm + { + private boolean _enabled; + private String _reminderTime; + private Integer _extensionLength; + private Integer _reminderFrequency; + private Integer _delayUntilFirstReminder; + + public boolean isEnabled() + { + return _enabled; + } + + public void setEnabled(boolean enabled) + { + _enabled = enabled; + } + + public String getReminderTime() + { + return _reminderTime; + } + + public void setReminderTime(String reminderTime) + { + _reminderTime = reminderTime; + } + + public Integer getExtensionLength() + { + return _extensionLength; + } + + public void setExtensionLength(Integer extensionLength) + { + _extensionLength = extensionLength; + } + + public Integer getReminderFrequency() + { + return _reminderFrequency; + } + + public void setReminderFrequency(Integer reminderFrequency) + { + _reminderFrequency = reminderFrequency; + } + + public Integer getDelayUntilFirstReminder() + { + return _delayUntilFirstReminder; + } + + public void setDelayUntilFirstReminder(Integer delayUntilFirstReminder) + { + _delayUntilFirstReminder = delayUntilFirstReminder; + } + } + + @RequiresPermission(AdminOperationsPermission.class) + public class SendPrivateDataRemindersAction extends FormViewAction + { + @Override + public void validateCommand(PrivateDataSendReminderForm form, Errors errors){} + + @Override + public ModelAndView getView(PrivateDataSendReminderForm form, boolean reshow, BindException errors) throws Exception + { + Journal panoramaPublic = JournalManager.getJournal(getContainer()); + if (panoramaPublic == null) + { + errors.reject(ERROR_MSG, "Not a Panorama Public folder: " + getContainer().getName()); + return new SimpleErrorView(errors, true); + } + + QuerySettings qSettings = new QuerySettings(getViewContext(), PanoramaPublicSchema.TABLE_EXPERIMENT_ANNOTATIONS, + PanoramaPublicSchema.TABLE_EXPERIMENT_ANNOTATIONS); + qSettings.setContainerFilterName(ContainerFilter.Type.CurrentAndSubfolders.name()); + qSettings.setBaseFilter(new SimpleFilter(FieldKey.fromParts("Public"), "No")); + + QueryView tableView = new QueryView(new PanoramaPublicSchema(getUser(), getContainer()), qSettings, null); + tableView.setTitle("Private Panorama Private Datasets"); + tableView.setFrame(WebPartView.FrameType.NONE); + tableView.disableContainerFilterSelection(); + + form.setDataRegionName(tableView.getDataRegionName()); + + JspView jspView = new JspView<>("/org/labkey/panoramapublic/view/sendPrivateDataRemindersForm.jsp", form, errors); + VBox view = new VBox(jspView, tableView); + view.setTitle("Send Reminders"); + view.setFrame(WebPartView.FrameType.PORTAL); + return view; + } + + @Override + public boolean handlePost(PrivateDataSendReminderForm form, BindException errors) throws Exception + { + List selectedExperimentIds = form.getSelectedExperimentIds(); + if (selectedExperimentIds.isEmpty()) + { + errors.reject(ERROR_MSG, "Please select at least one experiment"); + return false; + } + PipelineJob job = new PrivateDataReminderJob(getViewBackgroundInfo(), + PipelineService.get().getPipelineRootSetting(getContainer()), + JournalManager.getJournal(getContainer()), + form.getSelectedExperimentIds(), + form.getTestMode()); + PipelineService.get().queueJob(job); + return true; + } + + + @Override + public URLHelper getSuccessURL(PrivateDataSendReminderForm form) + { + return PageFlowUtil.urlProvider(PipelineUrls.class).urlBegin(getContainer()); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Private Data Reminder Settings", new ActionURL(PrivateDataReminderSettingsAction.class, ContainerManager.getRoot())); + root.addChild("Send Reminders"); + } + } + + public static class PrivateDataSendReminderForm + { + private boolean _testMode; + private String _selectedIds; + private String _dataRegionName = null; + + public boolean getTestMode() + { + return _testMode; + } + + public void setTestMode(boolean testMode) + { + _testMode = testMode; + } + + public String getSelectedIds() + { + return _selectedIds; + } + + public List getSelectedExperimentIds() + { + if (_selectedIds == null) + { + return Collections.emptyList(); + } + return Arrays.stream(StringUtils.split(_selectedIds, ",")).map(Integer::parseInt).collect(Collectors.toList()); + } + + public void setSelectedIds(String selectedIds) + { + _selectedIds = selectedIds; + } + + public String getDataRegionName() + { + return _dataRegionName; + } + + public void setDataRegionName(String dataRegionName) + { + _dataRegionName = dataRegionName; + } + } + + @RequiresAnyOf({AdminPermission.class, PanoramaPublicSubmitterPermission.class}) + public abstract class UpdateDatasetStatusAction extends ConfirmAction + { + protected ExperimentAnnotations _exptAnnotations; + protected DatasetStatus _datasetStatus; + + protected abstract void doValidationForAction(Errors errors); + protected abstract void updateDatasetStatus(DatasetStatus datasetStatus); + protected abstract void postNotification(); + protected abstract String getConfirmViewTitle(); + protected abstract String getConfirmViewMessage(); + + public ModelAndView getConfirmView(ShortUrlForm shortUrlForm, BindException errors) throws Exception + { + setTitle(getConfirmViewTitle()); + HtmlView view = new HtmlView(DIV( + DIV(getConfirmViewMessage()), + DIV("Title: " + _exptAnnotations.getTitle()), + DIV("Submitted on: " + DateUtil.formatDateTime(_exptAnnotations.getCreated(), PrivateDataReminderSettings.DATE_FORMAT_PATTERN)), + DIV("Submitter: " + _exptAnnotations.getSubmitterName()) + )); + view.setTitle(getConfirmViewTitle()); + return view; + } + + @Override + public void validateCommand(ShortUrlForm shortUrlForm, Errors errors) + { + _exptAnnotations = getValidExperimentAnnotations(shortUrlForm, errors); + if (_exptAnnotations == null) + { + return; + } + + ensureCorrectContainer(getContainer(), _exptAnnotations.getContainer(), getViewContext()); + + _datasetStatus = DatasetStatusManager.getForExperiment(_exptAnnotations); + + // Action-specific validation + doValidationForAction(errors); + } + + @Override + public boolean handlePost(ShortUrlForm shortUrlForm, BindException errors) throws Exception + { + try(DbScope.Transaction transaction = CoreSchema.getInstance().getSchema().getScope().ensureTransaction()) + { + if (_datasetStatus == null) + { + _datasetStatus = new DatasetStatus(); + _datasetStatus.setExperimentAnnotationsId(_exptAnnotations.getId()); + updateDatasetStatus(_datasetStatus); + DatasetStatusManager.save(_datasetStatus, getUser()); + } + else + { + updateDatasetStatus(_datasetStatus); + DatasetStatusManager.update(_datasetStatus, getUser()); + } + + postNotification(); + + transaction.commit(); + } + + return true; + } + + @Override + public @NotNull URLHelper getSuccessURL(ShortUrlForm shortUrlForm) + { + return PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(_exptAnnotations.getContainer()); + } + } + + @RequiresAnyOf({AdminPermission.class, PanoramaPublicSubmitterPermission.class}) + public class RequestExtensionAction extends UpdateDatasetStatusAction + { + @Override + protected String getConfirmViewTitle() + { + return "Request Extension For Panorama Public Data"; + } + + @Override + protected String getConfirmViewMessage() + { + return "You are requesting an extension for the private data on Panorama Public at " + _exptAnnotations.getShortUrl().renderShortURL(); + } + + @Override + protected void doValidationForAction(Errors errors) + { + if (_datasetStatus != null) + { + PrivateDataReminderSettings settings = PrivateDataReminderSettings.get(); + if (settings.isExtensionValid(_datasetStatus)) + { + errors.reject(ERROR_MSG, "An extension has already been requested for the data with short URL " + _exptAnnotations.getShortUrl().renderShortURL() + + ". The extension is valid until " + settings.extensionValidUntilFormatted(_datasetStatus)); + } + else if (_datasetStatus.deletionRequested()) + { + errors.reject(ERROR_MSG, "A deletion request was submitted on " + _datasetStatus.getDeletionRequestedDate() + + " for the data with short URL " + _exptAnnotations.getShortUrl().renderShortURL()); + } + } + } + + @Override + protected void updateDatasetStatus(DatasetStatus datasetStatus) + { + datasetStatus.setExtensionRequestedDate(new Date()); + } + + @Override + protected void postNotification() + { + // Post a message to the support thread. + JournalSubmission submission = SubmissionManager.getSubmissionForExperiment(_exptAnnotations); + Journal journal = JournalManager.getJournal(submission.getJournalId()); + PanoramaPublicNotification.postPrivateStatusExtensionMessage(journal, submission.getJournalExperiment(), _exptAnnotations, getUser()); + } + + @Override + public ModelAndView getSuccessView(ShortUrlForm shortUrlForm) + { + setTitle("Extension Request Success"); + PrivateDataReminderSettings settings = PrivateDataReminderSettings.get(); + return new HtmlView(DIV("An extension request was successfully submitted for the data at " + _exptAnnotations.getShortUrl().renderShortURL(), + DIV("The extension is valid until " + settings.extensionValidUntilFormatted(_datasetStatus)), + BR(), + DIV( + LinkBuilder.labkeyLink("Data Folder", PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(_exptAnnotations.getContainer())) + ) + )); + } + } + + @RequiresAnyOf({AdminPermission.class, PanoramaPublicSubmitterPermission.class}) + public class RequestDeletionAction extends UpdateDatasetStatusAction + { + @Override + protected String getConfirmViewTitle() + { + return "Request Deletion For Panorama Public Data"; + } + + @Override + protected String getConfirmViewMessage() + { + return "You are requesting deletion of the private data on Panorama Public at " + _exptAnnotations.getShortUrl().renderShortURL(); + } + + @Override + protected void doValidationForAction(Errors errors) + { + if (_datasetStatus != null) + { + if (_datasetStatus.deletionRequested()) + { + errors.reject(ERROR_MSG, "A deletion request was already submitted on " + _datasetStatus.getDeletionRequestedDateFormatted() + + " for the data with short URL " + _exptAnnotations.getShortUrl().renderShortURL()); + } + } + } + + @Override + protected void updateDatasetStatus(DatasetStatus datasetStatus) + { + datasetStatus.setDeletionRequestedDate(new Date()); + } + + @Override + protected void postNotification() + { + // Post a message to the support thread. + JournalSubmission submission = SubmissionManager.getSubmissionForExperiment(_exptAnnotations); + Journal journal = JournalManager.getJournal(submission.getJournalId()); + PanoramaPublicNotification.postDataDeletionRequestMessage(journal, submission.getJournalExperiment(), _exptAnnotations, getUser()); + } + + @Override + public ModelAndView getSuccessView(ShortUrlForm shortUrlForm) + { + setTitle("Deletion Request Success"); + return new HtmlView(DIV("A deletion request was successfully submitted for the data at " + _exptAnnotations.getShortUrl().renderShortURL(), + BR(), + DIV( + LinkBuilder.labkeyLink("Data Folder", PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(_exptAnnotations.getContainer())) + ) + )); + } + } + + public static class ShortUrlForm + { + private String _shortUrlEntityId; + + public String getShortUrlEntityId() + { + return _shortUrlEntityId; + } + + public void setShortUrlEntityId(String shortUrlEntityId) + { + _shortUrlEntityId = shortUrlEntityId; + } + } + + private static ExperimentAnnotations getValidExperimentAnnotations(ShortUrlForm shortUrlForm, Errors errors) + { + String shortUrlEntityId = shortUrlForm.getShortUrlEntityId(); + if (StringUtils.isBlank(shortUrlEntityId)) + { + errors.reject(ERROR_MSG, "ShortUrl is missing"); + return null; + } + + ShortURLRecord shortUrl = ShortURLService.get().getForEntityId(shortUrlEntityId); + if (shortUrl == null) + { + errors.reject(ERROR_MSG, "Cannot find a shortUrl for entityId " + shortUrlEntityId); + return null; + } + + ExperimentAnnotations exptAnnotations = ExperimentAnnotationsManager.getExperimentForShortUrl(shortUrl); + if (exptAnnotations == null) + { + errors.reject(ERROR_MSG, "Unable to find an experiment for short URL: " + shortUrl.renderShortURL()); + return null; + } + + if (exptAnnotations.isPublic()) + { + errors.reject(ERROR_MSG, "Data for short URL " + shortUrl.renderShortURL() + " is public. Status cannot be changed."); + return null; + } + + return exptAnnotations; + } + public static ActionURL getCopyExperimentURL(int experimentAnnotationsId, int journalId, Container container) { ActionURL result = new ActionURL(PanoramaPublicController.CopyExperimentAction.class, container); @@ -10216,7 +10748,8 @@ public void testActionPermissions() new DeleteJournalGroupAction(), new GetPxActionsAction(), new ExportPxXmlAction(), - new UpdatePxDetailsAction() + new UpdatePxDetailsAction(), + new PrivateDataReminderSettingsAction() ); // @AdminConsoleAction diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicManager.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicManager.java index e5338cd6..f959904d 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicManager.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicManager.java @@ -125,6 +125,11 @@ public static TableInfo getTableInfoCatalogEntry() return getSchema().getTable(PanoramaPublicSchema.TABLE_CATALOG_ENTRY); } + public static TableInfo getTableInfoDatasetStatus() + { + return getSchema().getTable(PanoramaPublicSchema.TABLE_DATASET_STATUS); + } + public static ITargetedMSRun getRunByLsid(String lsid, Container container) { return TargetedMSService.get().getRunByLsid(lsid, container); diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java index 31cbe681..6e4d2c6c 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java @@ -46,6 +46,7 @@ import org.labkey.panoramapublic.bluesky.BlueskyApiClient; import org.labkey.panoramapublic.bluesky.PanoramaPublicLogoResourceType; import org.labkey.panoramapublic.catalog.CatalogImageAttachmentType; +import org.labkey.panoramapublic.message.PrivateDataReminderSettings; import org.labkey.panoramapublic.model.Journal; import org.labkey.panoramapublic.model.speclib.SpecLibKey; import org.labkey.panoramapublic.pipeline.CopyExperimentPipelineProvider; @@ -91,7 +92,7 @@ public String getName() @Override public @Nullable Double getSchemaVersion() { - return 25.001; + return 25.003; } @Override @@ -382,6 +383,7 @@ public Set getUnitTests() set.add(Formula.TestCase.class); set.add(CatalogEntryManager.TestCase.class); set.add(BlueskyApiClient.TestCase.class); + set.add(PrivateDataReminderSettings.TestCase.class); return set; } diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicNotification.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicNotification.java index a6660883..2bb66369 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicNotification.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicNotification.java @@ -10,21 +10,26 @@ import org.labkey.api.announcements.api.AnnouncementService; import org.labkey.api.data.Container; import org.labkey.api.data.ContainerManager; +import org.labkey.api.markdown.MarkdownService; import org.labkey.api.portal.ProjectUrls; import org.labkey.api.security.User; import org.labkey.api.security.UserManager; import org.labkey.api.settings.AppProps; import org.labkey.api.settings.LookAndFeelProperties; +import org.labkey.api.util.DateUtil; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.view.ActionURL; import org.labkey.api.view.NotFoundException; import org.labkey.panoramapublic.datacite.DataCiteException; import org.labkey.panoramapublic.datacite.DataCiteService; +import org.labkey.panoramapublic.message.PrivateDataReminderSettings; import org.labkey.panoramapublic.model.ExperimentAnnotations; import org.labkey.panoramapublic.model.Journal; import org.labkey.panoramapublic.model.JournalExperiment; +import org.labkey.panoramapublic.model.JournalSubmission; import org.labkey.panoramapublic.model.Submission; import org.labkey.panoramapublic.proteomexchange.ProteomeXchangeService; +import org.labkey.panoramapublic.query.ExperimentAnnotationsManager; import org.labkey.panoramapublic.query.JournalManager; import org.labkey.panoramapublic.query.SubmissionManager; @@ -292,6 +297,130 @@ public static StringBuilder getFullMessageBody(String text, User messageTo, User return messageBody; } + public static void postPrivateStatusExtensionMessage(@NotNull Journal journal, @NotNull JournalExperiment je, @NotNull ExperimentAnnotations expAnnotations, User submitter) + { + User journalAdmin = JournalManager.getJournalAdminUser(journal); + if (journalAdmin == null) + { + throw new NotFoundException(String.format("Could not find an admin user for %s.", journal.getName())); + } + PrivateDataReminderSettings reminderSettings = PrivateDataReminderSettings.get(); + + String messageTitle = "Private Status Extended" +" - " + je.getShortAccessUrl().renderShortURL(); + StringBuilder messageBody = new StringBuilder(); + messageBody.append("Dear ").append(getUserName(submitter)).append(",").append(NL2); + messageBody.append("Thank you for your request to extend the private status of your data on Panorama Public. ") + .append("Your data has been granted a " + reminderSettings.getExtensionLength() + " month extension. ") + .append("You will receive another reminder when this period ends. ") + .append("If you'd like to make your data public sooner, you can do so at any time ") + .append("by clicking the \"Make Public\" button in your data folder, or by clicking this link: ") + .append(bold(link("Make Data Public", PanoramaPublicController.getMakePublicUrl(expAnnotations.getId(), expAnnotations.getContainer()).getURIString()))) + .append("."); + messageBody.append(NL2).append("Best regards,"); + messageBody.append(NL).append(getUserName(journalAdmin)); + + postNotificationFullTitle(journal, je, messageBody.toString(), journalAdmin, messageTitle, StatusOption.Closed, null); + } + + public static void postDataDeletionRequestMessage(@NotNull Journal journal, @NotNull JournalExperiment je, @NotNull ExperimentAnnotations expAnnotations, User submitter) + { + User journalAdmin = JournalManager.getJournalAdminUser(journal); + if (journalAdmin == null) + { + throw new NotFoundException(String.format("Could not find an admin user for %s.", journal.getName())); + } + + ExperimentAnnotations sourceExperiment = ExperimentAnnotationsManager.get(expAnnotations.getSourceExperimentId()); + + String messageTitle = "Data Deletion Requested" +" - " + expAnnotations.getShortUrl().renderShortURL(); + + StringBuilder messageBody = new StringBuilder(); + messageBody.append("Dear ").append(getUserName(submitter)).append(",").append(NL2); + messageBody.append("Thank you for your request to delete your data on Panorama Public. ") + .append("We will remove your data from Panorama Public. "); + if (sourceExperiment != null) + { + messageBody.append("Your source folder ") + .append(getContainerLink(sourceExperiment.getContainer())) + .append(" will remain intact, allowing you to resubmit the data in the future if you wish. "); + } + else + { + messageBody.append("We were unable to locate the source folder for this data in your project. ") + .append("The folder at the path ") + .append(expAnnotations.getSourceExperimentPath()) + .append(" may have been deleted."); + } + + messageBody.append(NL2).append("Best regards,"); + messageBody.append(NL).append(getUserName(journalAdmin)); + + postNotificationFullTitle(journal, je, messageBody.toString(), journalAdmin, messageTitle, StatusOption.Active, null); + } + + + public static void postPrivateDataReminderMessage(@NotNull Journal journal, @NotNull JournalSubmission js, @NotNull ExperimentAnnotations expAnnotations, + @NotNull User submitter, @NotNull User messagePoster, List notifyUsers, + @NotNull Announcement announcement, @NotNull Container announcementsContainer, @NotNull User journalAdmin) + { + String message = getDataStatusReminderMessage(expAnnotations, submitter, js, announcement, announcementsContainer, journalAdmin); + String title = "Action Required: Status Update for Your Private Data on Panorama Public"; + postNotificationFullTitle(journal, js.getJournalExperiment(), message, messagePoster, title, StatusOption.Closed, notifyUsers); + } + + public static String getDataStatusReminderMessage(@NotNull ExperimentAnnotations exptAnnotations, @NotNull User submitter, + @NotNull JournalSubmission js,@NotNull Announcement announcement, + @NotNull Container announcementContainer, @NotNull User journalAdmin) + { + String shortUrl = exptAnnotations.getShortUrl().renderShortURL(); + String makePublicLink = PanoramaPublicController.getMakePublicUrl(exptAnnotations.getId(), exptAnnotations.getContainer()).getURIString(); + String dateString = DateUtil.formatDateTime(js.getLatestSubmission().getCreated(), PrivateDataReminderSettings.DATE_FORMAT_PATTERN); + + ActionURL viewMessageUrl = new ActionURL("announcements", "thread", announcementContainer) + .addParameter("rowId", announcement.getRowId()); + ActionURL respondToMessageUrl = new ActionURL("announcements", "respond", announcementContainer) + .addParameter("parentId", announcement.getEntityId()) + .addReturnUrl(viewMessageUrl); + + String shortUrlEntityId = exptAnnotations.getShortUrl().getEntityId().toString(); + ActionURL requestExtensionUrl = new ActionURL(PanoramaPublicController.RequestExtensionAction.class, exptAnnotations.getContainer()) + .addParameter("shortUrlEntityId", shortUrlEntityId); + + ActionURL requesDeletionUrl = new ActionURL(PanoramaPublicController.RequestDeletionAction.class, exptAnnotations.getContainer()) + .addParameter("shortUrlEntityId",shortUrlEntityId); + + + ExperimentAnnotations sourceExperiment = ExperimentAnnotationsManager.get(exptAnnotations.getSourceExperimentId()); + + StringBuilder message = new StringBuilder(); + message.append("Dear ").append(getUserName(submitter)).append(",").append(NL2) + .append("We are reaching out regarding your data on Panorama Public (").append(shortUrl).append("), which has been private since ") + .append(dateString).append(".") + .append("\n\n**Is the paper associated with this work already published?**") + .append("\n- If yes: Please make your data public by clicking the \"Make Public\" button in your folder or by clicking this link: ") + .append(bold(link("Make Data Public", makePublicLink))) + .append(". This helps ensure that your valuable research is easily accessible to the community.") + .append("\n- If not: You have a couple of options:") + .append("\n - **Request an Extension** - If your paper is still under review, or you need additional time, please let us know by clicking ") + .append(bold(link("Request Extension", requestExtensionUrl.getURIString()))).append(".") + .append("\n - **Delete from Panorama Public** - If you no longer wish to host your data on Panorama Public, please click ") + .append(bold(link("Request Deletion", requesDeletionUrl.getURIString()))).append(". ") + .append("We will remove your data from Panorama Public."); + if (sourceExperiment != null) + { + message.append(" However, your source folder (") + .append(getContainerLink(sourceExperiment.getContainer())) + .append(") will remain intact, allowing you to resubmit your data in the future if you wish."); + } + + message.append("\n\nIf you have any questions or need further assistance, please do not hesitate to respond to this message by ") + .append(bold(link("clicking here", respondToMessageUrl.getURIString()))).append(".") + .append("\n\nThank you for sharing your research on Panorama Public. We appreciate your commitment to open science and your contributions to the research community.") + .append(NL2).append("Best regards,") + .append(NL).append(getUserName(journalAdmin)); + return message.toString(); + } + // The following link placeholders can be used in messages posted through the Panorama Public admin console (PostPanoramaPublicMessageAction). // An example message (Markdown format): /* @@ -449,9 +578,13 @@ private static String escape(String text) { // https://www.markdownguide.org/basic-syntax/#characters-you-can-escape // Escape Markdown special characters. Some character combinations can result in - // unintended Markdown styling, e.g. "+_Italics_+" will results in "Italics" to be italicized. + // unintended Markdown styling, e.g. "+_Italics_+" results in "Italics" to be italicized. // This can be seen with the tricky characters used for project names in labkey tests. - return text.replaceAll("([`*_{}\\[\\]()#+.!|-])", "\\\\$1"); + // 8/13/25 - Escape tilde (~) as well. In the LabKey Markdown flavor, text between + // single tildes (e.g., ~strikethrough~) is rendered as strikethrough. + // IMPORTANT: The dash (-) must be escaped in the regex or placed at the start/end of the + // character class to be treated as a literal dash rather than a range operator. + return text.replaceAll("([`*_{}\\[\\]()#+.!|~-])", "\\\\$1"); } public static String getExperimentCopiedMessageBody(ExperimentAnnotations sourceExperiment, @@ -572,8 +705,24 @@ public static class TestCase extends Assert public void testMarkdownEscape() { Assert.assertEquals("\\+\\_Test\\_\\+", escape("+_Test_+")); - Assert.assertEquals("PanoramaPublicTest Project ☃~\\!@$&\\(\\)\\_\\+\\{\\}\\-=\\[\\],\\.\\#äöüÅ", - escape("PanoramaPublicTest Project ☃~!@$&()_+{}-=[],.#äöüÅ")); + String expected = "PanoramaPublicTest Project ☃\\~\\!@$&\\(\\)\\_\\+\\{\\}\\-=\\[\\],\\.\\#äöüÅ"; + String escaped = escape("PanoramaPublicTest Project ☃~!@$&()_+{}-=[],.#äöüÅ"); + Assert.assertEquals(expected, escaped); + + /* + PanoramaPublicTest Project ☃~!@$&()_+{}-=[],.#äöüÅ This is a test PanoramaPublicTest Project ☃~!@$&()_+{}-=[],.#äöüÅ + + should be translated to + +

PanoramaPublicTest Project ☃~!@$&()_+{}-=[],.#äöüÅ This is a test PanoramaPublicTest Project ☃~!@$&()_+{}-=[],.#äöüÅ

+
+ */ + MarkdownService mds = MarkdownService.get(); + expected = """ +

PanoramaPublicTest Project ☃~!@$&()_+{}-=[],.#äöüÅ This is a test PanoramaPublicTest Project ☃~!@$&()_+{}-=[],.#äöüÅ

+
"""; + String testText = "PanoramaPublicTest Project ☃~!@$&()_+{}-=[],.#äöüÅ This is a test PanoramaPublicTest Project ☃~!@$&()_+{}-=[],.#äöüÅ"; + Assert.assertEquals(expected, mds.toHtml(escape(testText))); } } } diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicSchema.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicSchema.java index ed731a4f..c40c0826 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicSchema.java +++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicSchema.java @@ -52,6 +52,7 @@ import org.labkey.panoramapublic.query.CatalogEntryTableInfo; import org.labkey.panoramapublic.query.ContainerJoin; import org.labkey.panoramapublic.query.DataValidationTableInfo; +import org.labkey.panoramapublic.query.DatasetStatusTableInfo; import org.labkey.panoramapublic.query.ExperimentAnnotationsTableInfo; import org.labkey.panoramapublic.query.JournalExperimentTableInfo; import org.labkey.panoramapublic.query.MyDataTableInfo; @@ -95,6 +96,7 @@ public class PanoramaPublicSchema extends UserSchema public static final String TABLE_LIB_SOURCE_TYPE = "SpecLibSourceType"; public static final String TABLE_CATALOG_ENTRY = "CatalogEntry"; + public static final String TABLE_DATASET_STATUS = "DatasetStatus"; public PanoramaPublicSchema(User user, Container container) { @@ -317,6 +319,11 @@ public TableInfo createTable(String name, ContainerFilter cf) return new CatalogEntryTableInfo(this, cf); } + if (TABLE_DATASET_STATUS.equalsIgnoreCase(name)) + { + return new DatasetStatusTableInfo(this, cf); + } + return null; } @@ -440,6 +447,7 @@ public Set getTableNames() hs.add(TABLE_EXPT_STRUCTURAL_MOD_INFO); hs.add(TABLE_EXPT_ISOTOPE_MOD_INFO); hs.add(TABLE_CATALOG_ENTRY); + hs.add(TABLE_DATASET_STATUS); return hs; } } diff --git a/panoramapublic/src/org/labkey/panoramapublic/message/PrivateDataMessageScheduler.java b/panoramapublic/src/org/labkey/panoramapublic/message/PrivateDataMessageScheduler.java new file mode 100644 index 00000000..90101344 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/message/PrivateDataMessageScheduler.java @@ -0,0 +1,128 @@ +package org.labkey.panoramapublic.message; + +import org.apache.logging.log4j.Logger; +import org.labkey.api.data.Container; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.security.User; +import org.labkey.api.util.ConfigurationException; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.panoramapublic.model.Journal; +import org.labkey.panoramapublic.pipeline.PrivateDataReminderJob; +import org.labkey.panoramapublic.query.JournalManager; +import org.quartz.CronScheduleBuilder; +import org.quartz.Job; +import org.quartz.JobBuilder; +import org.quartz.JobDetail; +import org.quartz.JobExecutionContext; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.Trigger; +import org.quartz.TriggerBuilder; +import org.quartz.TriggerKey; +import org.quartz.impl.StdSchedulerFactory; + +import java.time.LocalTime; + +public class PrivateDataMessageScheduler +{ + private static final Logger _log = LogHelper.getLogger(PrivateDataMessageScheduler.class, "Panorama Public private data reminder message scheduler"); + + private static final TriggerKey TRIGGER_KEY = new TriggerKey(PrivateDataMessageScheduler.class.getCanonicalName()); + + private static final PrivateDataMessageScheduler _instance = new PrivateDataMessageScheduler(); + + public static PrivateDataMessageScheduler getInstance() + { + return _instance; + } + + private PrivateDataMessageScheduler(){} + + public void initialize(boolean enable) + { + try + { + Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler(); + + // Clear previous job, if present + if (scheduler.checkExists(TRIGGER_KEY)) + scheduler.unscheduleJob(TRIGGER_KEY); + + if (!enable) + { + return; + } + + PrivateDataReminderSettings settings = PrivateDataReminderSettings.get(); + // Get the quartz Trigger + Trigger trigger = getTrigger(settings); + + // Create a quartz job that queues a pipeline job that posts private data reminder messages + JobDetail job = JobBuilder.newJob(PrivateDataMessageSchedulerJob.class) + .withIdentity(PrivateDataMessageScheduler.class.getCanonicalName()) + .build(); + + // Schedule trigger to send reminders on the configured schedule + scheduler.scheduleJob(job, trigger); + } + catch (SchedulerException e) + { + throw new RuntimeException("Failed to schedule PrivateDataMessageScheduler job", e); + } + } + + protected Trigger getTrigger(PrivateDataReminderSettings settings) + { + LocalTime reminderTime = settings.getReminderTime(); + + // Runs every day at the specified time + return TriggerBuilder.newTrigger() + .withIdentity(TRIGGER_KEY) + .withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(reminderTime.getHour(), reminderTime.getMinute())) + .build(); + } + + public static class PrivateDataMessageSchedulerJob implements Job + { + @SuppressWarnings("unused") + public PrivateDataMessageSchedulerJob() {} + + @Override + public void execute(JobExecutionContext context) + { + try + { + Journal panoramaPublic = JournalManager.getJournal(JournalManager.PANORAMA_PUBLIC); + if (panoramaPublic == null) + { + throw new ConfigurationException("Server does not have a Panorama Public project."); + } + + Container c = panoramaPublic.getProject(); + + User panoramaPublicAdmin = JournalManager.getJournalAdminUser(panoramaPublic); + if (panoramaPublicAdmin == null) + { + throw new ConfigurationException("Unable to find an admin user in the Panorama Public project."); + } + ViewBackgroundInfo vbi = new ViewBackgroundInfo(c, panoramaPublicAdmin, null); + PipeRoot root = PipelineService.get().findPipelineRoot(c); + + if (root == null || !root.isValid()) + { + throw new ConfigurationException("No valid pipeline root found in the container " + c.getName()); + } + + PipelineJob job = new PrivateDataReminderJob(vbi, PipelineService.get().getPipelineRootSetting(c), false); + PipelineService.get().queueJob(job); + } + catch(Exception e) + { + _log.error("Error queuing PrivateDataReminderJob", e); + } + } + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/message/PrivateDataReminderSettings.java b/panoramapublic/src/org/labkey/panoramapublic/message/PrivateDataReminderSettings.java new file mode 100644 index 00000000..ee45663c --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/message/PrivateDataReminderSettings.java @@ -0,0 +1,421 @@ +package org.labkey.panoramapublic.message; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.Assert; +import org.junit.Test; +import org.labkey.api.data.PropertyManager; +import org.labkey.api.util.DateUtil; +import org.labkey.panoramapublic.model.DatasetStatus; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Date; + +public class PrivateDataReminderSettings +{ + public static final String PROP_PRIVATE_DATA_REMINDER = "Panorama Public private data reminder settings"; + public static final String PROP_ENABLE_REMINDER = "Enable private data reminder"; + public static final String PROP_REMINDER_TIME = "Reminder time"; + public static final String PROP_DELAY_UNTIL_FIRST_REMINDER = "Delay until first reminder (months)"; + public static final String PROP_REMINDER_FREQUENCY = "Reminder frequency (months)"; + public static final String PROP_EXTENSION_LENGTH = "Extension duration (months)"; + + private static final boolean DEFAULT_ENABLE_REMINDERS = false; + public static final String DEFAULT_REMINDER_TIME = "8:00 AM"; + private static final int DEFAULT_DELAY_UNTIL_FIRST_REMINDER = 12; // Send the first reminder after the data has been private for a year. + private static final int DEFAULT_REMINDER_FREQUENCY = 1; // Send reminders once a month, unless extension or deletion was requested. + private static final int DEFAULT_EXTENSION_LENGTH = 6; // Private status of a dataset can be extended by 6 months. + + public static final String DATE_FORMAT_PATTERN = "MMMM d, yyyy"; + public static final String REMINDER_TIME_FORMAT = "h:mm a"; + private static final DateTimeFormatter reminderTimeFormatter = DateTimeFormatter.ofPattern(REMINDER_TIME_FORMAT); + + private boolean _enableReminders; + private LocalTime _reminderTime; + private int _delayUntilFirstReminder; + private int _reminderFrequency; + private int _extensionLength; + + public static PrivateDataReminderSettings get() + { + PropertyManager.WritablePropertyMap settingsMap = PropertyManager.getWritableProperties(PROP_PRIVATE_DATA_REMINDER, false); + + PrivateDataReminderSettings settings = new PrivateDataReminderSettings(); + if(settingsMap != null) + { + boolean enableReminders = settingsMap.get(PROP_ENABLE_REMINDER) == null + ? DEFAULT_ENABLE_REMINDERS + : Boolean.valueOf(settingsMap.get(PROP_ENABLE_REMINDER)); + settings.setEnableReminders(enableReminders); + + int delayUntilFirstReminder = settingsMap.get(PROP_DELAY_UNTIL_FIRST_REMINDER) == null + ? DEFAULT_DELAY_UNTIL_FIRST_REMINDER + : Integer.valueOf(settingsMap.get(PROP_DELAY_UNTIL_FIRST_REMINDER)); + settings.setDelayUntilFirstReminder(delayUntilFirstReminder); + + int reminderFrequency = settingsMap.get(PROP_REMINDER_FREQUENCY) == null + ? DEFAULT_REMINDER_FREQUENCY + : Integer.valueOf(settingsMap.get(PROP_REMINDER_FREQUENCY)); + settings.setReminderFrequency(reminderFrequency); + + int extensionLength = settingsMap.get(PROP_EXTENSION_LENGTH) == null + ? DEFAULT_EXTENSION_LENGTH + : Integer.valueOf(settingsMap.get(PROP_EXTENSION_LENGTH)); + settings.setExtensionLength(extensionLength); + + LocalTime reminderTime = tryParseReminderTime(settingsMap.get(PROP_REMINDER_TIME), DEFAULT_REMINDER_TIME); + settings.setReminderTime(reminderTime); + } + else + { + settings.setEnableReminders(DEFAULT_ENABLE_REMINDERS); + settings.setDelayUntilFirstReminder(DEFAULT_DELAY_UNTIL_FIRST_REMINDER); + settings.setReminderFrequency(DEFAULT_REMINDER_FREQUENCY); + settings.setExtensionLength(DEFAULT_EXTENSION_LENGTH); + settings.setReminderTime(parseReminderTime(DEFAULT_REMINDER_TIME)); + } + + return settings; + } + + private static LocalTime tryParseReminderTime(String timeString, String defaultTime) + { + LocalTime reminderTime = parseReminderTime(timeString); + if (reminderTime == null) + { + reminderTime = parseReminderTime(defaultTime); + } + return reminderTime; + } + + public static @Nullable LocalTime parseReminderTime(String timeString) + { + try + { + return timeString != null ? LocalTime.parse(timeString, reminderTimeFormatter) : null; + } + catch(DateTimeParseException ignored) {} + + return null; + } + + public static void save(PrivateDataReminderSettings settings) + { + PropertyManager.WritablePropertyMap settingsMap = PropertyManager.getWritableProperties(PROP_PRIVATE_DATA_REMINDER, true); + settingsMap.put(PROP_ENABLE_REMINDER, String.valueOf(settings.isEnableReminders())); + settingsMap.put(PROP_DELAY_UNTIL_FIRST_REMINDER, String.valueOf(settings.getDelayUntilFirstReminder())); + settingsMap.put(PROP_REMINDER_FREQUENCY, String.valueOf(settings.getReminderFrequency())); + settingsMap.put(PROP_EXTENSION_LENGTH, String.valueOf(settings.getExtensionLength())); + settingsMap.put(PROP_REMINDER_TIME, settings.getReminderTimeFormatted()); + settingsMap.save(); + } + + public void setEnableReminders(boolean enableReminders) + { + _enableReminders = enableReminders; + } + + public void setReminderTime(LocalTime reminderTime) + { + _reminderTime = reminderTime; + } + + public void setExtensionLength(int extensionLength) + { + _extensionLength = extensionLength; + } + + public void setReminderFrequency(int reminderFrequency) + { + _reminderFrequency = reminderFrequency; + } + + public boolean isEnableReminders() + { + return _enableReminders; + } + + public LocalTime getReminderTime() + { + return _reminderTime; + } + + public String getReminderTimeFormatted() + { + return _reminderTime != null ? _reminderTime.format(reminderTimeFormatter) : "Reminder time not set"; + } + + public int getExtensionLength() + { + return _extensionLength; + } + + public int getReminderFrequency() + { + return _reminderFrequency; + } + + public int getDelayUntilFirstReminder() + { + return _delayUntilFirstReminder; + } + + public void setDelayUntilFirstReminder(int delayUntilFirstReminder) + { + _delayUntilFirstReminder = delayUntilFirstReminder; + } + + public @Nullable Date getReminderValidUntilDate(@NotNull DatasetStatus status) + { + return status.getLastReminderDate() == null ? null : addMonths(status.getLastReminderDate(), getReminderFrequency()); + } + + public boolean isLastReminderRecent(@NotNull DatasetStatus status) + { + return isDateInFuture(getReminderValidUntilDate(status)); + } + + public @Nullable Date getExtensionValidUntilDate(@NotNull DatasetStatus status) + { + return status.getExtensionRequestedDate() == null ? null : addMonths(status.getExtensionRequestedDate(), getExtensionLength()); + } + + public boolean isExtensionValid(@NotNull DatasetStatus status) + { + return isDateInFuture(getExtensionValidUntilDate(status)); + } + + public @Nullable String extensionValidUntilFormatted(@NotNull DatasetStatus status) + { + Date date = getExtensionValidUntilDate(status); + return date != null ? format(date) : null; + } + + public static String format(@NotNull Date date) + { + return DateUtil.formatDateTime(date, DATE_FORMAT_PATTERN); + } + + private static boolean isDateInFuture(@Nullable Date date) + { + return isDateInFuture(date, new Date()); + } + + private static boolean isDateInFuture(@Nullable Date date, @NotNull Date currentTime) + { + return date != null && date.after(currentTime); + } + + private static Date addMonths(@NotNull Date date, int months) + { + return Date.from(dateToZonedDateTime(date).plusMonths(months).toInstant()); + } + + private static ZonedDateTime dateToZonedDateTime(@NotNull Date date) + { + return date.toInstant().atZone(ZoneId.systemDefault()); + } + + private boolean isExtensionValidAsOf(@NotNull DatasetStatus status, @NotNull Date currentTime) + { + Date extensionValidUntil = getExtensionValidUntilDate(status); + return isDateInFuture(extensionValidUntil, currentTime); + } + + private boolean isLastReminderRecentAsOf(@NotNull DatasetStatus status, @NotNull Date currentTime) + { + Date reminderValidUntil = getReminderValidUntilDate(status); + return isDateInFuture(reminderValidUntil, currentTime); + } + + public static class TestCase extends Assert + { + @Test + public void testIsExtensionCurrentScenarios() + { + DatasetStatus datasetStatus = new DatasetStatus(); + PrivateDataReminderSettings settings = createTestSettingsExtensionLength(6); + + // No extension requested (extensionRequestDate is null) + assertFalse("Should return false; extensionRequestDate is null", settings.isExtensionValid(datasetStatus)); + + // Extension request is within the configured extension period + testExtensionIsValid(settings, -3); + + // Extension request has expired + testExtensionIsExpired(settings, -7); + + // Extension request expires exactly now, not in the future. + testExtensionIsExpiredAsOf(settings, (settings.getExtensionLength() * -1), 0); + + // Extension expires in 1 minute - still current + testExtensionIsValidAsOf(settings, (settings.getExtensionLength() * -1), 1); + + // Extension expired 1 minute ago + testExtensionIsExpiredAsOf(settings, (settings.getExtensionLength() * -1), -1); + } + + @Test + public void testIsLastReminderRecentScenarios() + { + DatasetStatus datasetStatus = new DatasetStatus(); + PrivateDataReminderSettings settings = createTestSettingsReminderFrequency(2); + + // No reminder sent yet (lastReminderDate is null) + assertFalse("Should return false; lastReminderDate is null", settings.isLastReminderRecent(datasetStatus)); + + // Last reminder sent within the reminder frequency period + testReminderIsRecent(settings, -15); + + // Reminder is old + testReminderIsOld(settings, -70); + + // Reminder is old now + testReminderIsOldAsOf(settings, (settings.getReminderFrequency() * -1), 0); + + // Reminder gets old in 1 minute - still current + testReminderIsRecentAsOf(settings, (settings.getReminderFrequency() * -1), 1); + + // Reminder became old 1 minute ago + testReminderIsOldAsOf(settings, (settings.getReminderFrequency() * -1), -1); + + // Setting the reminder frequency to 0 will return false for isReminderRecent, unless lastReminderDate is set in the future. + settings = createTestSettingsReminderFrequency(0); + datasetStatus = new DatasetStatus(); + assertFalse("Should return false; lastReminderDate is null", settings.isLastReminderRecent(datasetStatus)); + testReminderIsOld(settings, -15); + testReminderIsOldAsOf(settings, (settings.getReminderFrequency() * -1), 0); + testReminderIsRecent(settings, 30); // Reminder date is set in the future + } + + private void testExtensionIsValid(PrivateDataReminderSettings settings, int monthsOffset) + { + testExtensionIsValid(settings, monthsOffset, 0, null, true); + } + + private void testExtensionIsExpired(PrivateDataReminderSettings settings, int monthsOffset) + { + testExtensionIsValid(settings, monthsOffset, 0, null, false); + } + + private void testExtensionIsValidAsOf(PrivateDataReminderSettings settings, int monthsOffset, int minutesOffset) + { + testExtensionIsValid(settings, monthsOffset, minutesOffset, dateFromNow(), true); + } + + private void testExtensionIsExpiredAsOf(PrivateDataReminderSettings settings, int monthsOffset, int minutesOffset) + { + testExtensionIsValid(settings, monthsOffset, minutesOffset, dateFromNow(), false); + } + + private void testExtensionIsValid(PrivateDataReminderSettings settings, int monthsOffset, int minutesOffset, + Date currentDate, boolean expectedValid) + { + Date extensionDate = dateFromNow(monthsOffset, 0, minutesOffset); + + DatasetStatus datasetStatus = new DatasetStatus(); + datasetStatus.setExtensionRequestedDate(extensionDate); + + String failureMessage = String.format("Extension is %s; Extension Length: %d; Extension Requested On: %s; Valid Until: %s", + expectedValid ? "valid" : "expired", + settings.getExtensionLength(), + datasetStatus.getExtensionRequestedDate(), + settings.getExtensionValidUntilDate(datasetStatus)); + if (currentDate != null) + { + failureMessage += String.format("; Current Date: %s", currentDate); + } + + boolean isValid = currentDate == null + ? settings.isExtensionValid(datasetStatus) + : settings.isExtensionValidAsOf(datasetStatus, currentDate); + if (expectedValid) assertTrue(failureMessage, isValid); + else assertFalse(failureMessage, isValid); + } + + private void testReminderIsRecent(PrivateDataReminderSettings settings, int daysOffset) + { + testReminderIsRecent(settings, 0, daysOffset, 0, null, true); + } + + private void testReminderIsOld(PrivateDataReminderSettings settings, int daysOffset) + { + testReminderIsRecent(settings, 0, daysOffset, 0, null, false); + } + + private void testReminderIsRecentAsOf(PrivateDataReminderSettings settings, int monthsOffset, int minutesOffset) + { + testReminderIsRecent(settings, monthsOffset, 0, minutesOffset, dateFromNow(), true); + } + + private void testReminderIsOldAsOf(PrivateDataReminderSettings settings, int monthsOffset, int minutesOffset) + { + testReminderIsRecent(settings, monthsOffset, 0, minutesOffset, dateFromNow(), false); + } + + private void testReminderIsRecent(PrivateDataReminderSettings settings, int monthsOffset, int daysOffset, int minutesOffset, + Date currentDate, boolean expectedRecent) + { + Date reminderDate = dateFromNow(monthsOffset, daysOffset, minutesOffset); + + DatasetStatus datasetStatus = new DatasetStatus(); + datasetStatus.setLastReminderDate(reminderDate); + + String failureMessage = String.format("Reminder is %s; Reminder Frequency: %d; Reminder Sent On: %s; Valid Until: %s", + expectedRecent ? "recent" : "old", + settings.getReminderFrequency(), + datasetStatus.getLastReminderDate(), + settings.getReminderValidUntilDate(datasetStatus)); + if (currentDate != null) + { + failureMessage += String.format("; Current Date: %s", currentDate); + } + + boolean isValid = currentDate == null + ? settings.isLastReminderRecent(datasetStatus) + : settings.isLastReminderRecentAsOf(datasetStatus, currentDate); + if (expectedRecent) assertTrue(failureMessage, isValid); + else assertFalse(failureMessage, isValid); + } + + private PrivateDataReminderSettings createTestSettingsExtensionLength(int extensionLength) + { + return createTestSettings(extensionLength, 0); + } + + private PrivateDataReminderSettings createTestSettingsReminderFrequency(int reminderFrequency) + { + return createTestSettings(0, reminderFrequency); + } + + private PrivateDataReminderSettings createTestSettings(int extensionLength, int reminderFrequency) + { + PrivateDataReminderSettings testSettings = new PrivateDataReminderSettings(); + testSettings.setExtensionLength(extensionLength); + testSettings.setReminderFrequency(reminderFrequency); + return testSettings; + } + + private Date dateFromNow() + { + return dateFromNow(0, 0, 0); + } + + private Date dateFromNow(int monthsOffset, int daysOffset, int minutesOffset) + { + return Date.from( + LocalDate.now() + .plusMonths(monthsOffset) + .plusDays(daysOffset) + .atStartOfDay(ZoneId.systemDefault()) + .plusMinutes(minutesOffset) + .toInstant() + ); + } + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/model/DatasetStatus.java b/panoramapublic/src/org/labkey/panoramapublic/model/DatasetStatus.java new file mode 100644 index 00000000..717da27d --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/model/DatasetStatus.java @@ -0,0 +1,75 @@ +package org.labkey.panoramapublic.model; + +import org.jetbrains.annotations.Nullable; +import org.labkey.api.view.ShortURLRecord; +import org.labkey.panoramapublic.message.PrivateDataReminderSettings; + +import java.util.Date; + +public class DatasetStatus extends DbEntity +{ + private int _experimentAnnotationsId; + private Date _lastReminderDate; + private Date _extensionRequestedDate; + private Date _deletionRequestedDate; + + public int getExperimentAnnotationsId() + { + return _experimentAnnotationsId; + } + + public void setExperimentAnnotationsId(int experimentAnnotationsId) + { + _experimentAnnotationsId = experimentAnnotationsId; + } + + public Date getLastReminderDate() + { + return _lastReminderDate; + } + + public void setLastReminderDate(Date lastReminderDate) + { + _lastReminderDate = lastReminderDate; + } + + public Date getExtensionRequestedDate() + { + return _extensionRequestedDate; + } + + public void setExtensionRequestedDate(Date extensionRequestedDate) + { + _extensionRequestedDate = extensionRequestedDate; + } + + public Date getDeletionRequestedDate() + { + return _deletionRequestedDate; + } + + public @Nullable String getDeletionRequestedDateFormatted() + { + return PrivateDataReminderSettings.format(_deletionRequestedDate); + } + + public void setDeletionRequestedDate(Date deletionRequestedDate) + { + _deletionRequestedDate = deletionRequestedDate; + } + + public boolean deletionRequested() + { + return _deletionRequestedDate != null; + } + + public boolean extensionRequested() + { + return _extensionRequestedDate != null; + } + + public boolean reminderSent() + { + return _lastReminderDate != null; + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/pipeline/CopyExperimentFinalTask.java b/panoramapublic/src/org/labkey/panoramapublic/pipeline/CopyExperimentFinalTask.java index cb2c0138..362f450e 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/pipeline/CopyExperimentFinalTask.java +++ b/panoramapublic/src/org/labkey/panoramapublic/pipeline/CopyExperimentFinalTask.java @@ -74,6 +74,7 @@ import org.labkey.panoramapublic.proteomexchange.ProteomeXchangeService; import org.labkey.panoramapublic.proteomexchange.ProteomeXchangeServiceException; import org.labkey.panoramapublic.query.CatalogEntryManager; +import org.labkey.panoramapublic.query.DatasetStatusManager; import org.labkey.panoramapublic.query.ExperimentAnnotationsManager; import org.labkey.panoramapublic.query.JournalManager; import org.labkey.panoramapublic.query.SubmissionManager; diff --git a/panoramapublic/src/org/labkey/panoramapublic/pipeline/PrivateDataReminderJob.java b/panoramapublic/src/org/labkey/panoramapublic/pipeline/PrivateDataReminderJob.java new file mode 100644 index 00000000..2b5bfe6b --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/pipeline/PrivateDataReminderJob.java @@ -0,0 +1,627 @@ +package org.labkey.panoramapublic.pipeline; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.announcements.api.Announcement; +import org.labkey.api.announcements.api.AnnouncementService; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DbScope; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineJob; +import org.labkey.api.portal.ProjectUrls; +import org.labkey.api.security.User; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.StringUtilsLabKey; +import org.labkey.api.util.URLHelper; +import org.labkey.api.view.ViewBackgroundInfo; +import org.labkey.panoramapublic.PanoramaPublicManager; +import org.labkey.panoramapublic.PanoramaPublicNotification; +import org.labkey.panoramapublic.message.PrivateDataReminderSettings; +import org.labkey.panoramapublic.model.DatasetStatus; +import org.labkey.panoramapublic.model.ExperimentAnnotations; +import org.labkey.panoramapublic.model.Journal; +import org.labkey.panoramapublic.model.JournalSubmission; +import org.labkey.panoramapublic.query.DatasetStatusManager; +import org.labkey.panoramapublic.query.ExperimentAnnotationsManager; +import org.labkey.panoramapublic.query.JournalManager; +import org.labkey.panoramapublic.query.SubmissionManager; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class PrivateDataReminderJob extends PipelineJob +{ + private boolean _test; + private List _experimentAnnotationsIds; + private Journal _panoramaPublic; + + protected PrivateDataReminderJob() + { + } + + public PrivateDataReminderJob(ViewBackgroundInfo info, @NotNull PipeRoot root, boolean test) + { + this(info, root, getPanoramaPublic(), getPrivateDatasets(getPanoramaPublic()), test); + } + + public PrivateDataReminderJob(ViewBackgroundInfo info, @NotNull PipeRoot root, Journal panoramaPublic, List experimentAnnotationsIds, boolean test) + { + super("Panorama Public", info, root); + setLogFile(root.getRootFileLike().toNioPathForWrite().resolve(FileUtil.makeFileNameWithTimestamp("PanoramaPublic-private-data-reminder", "log"))); + _panoramaPublic = panoramaPublic; + + _experimentAnnotationsIds = experimentAnnotationsIds; + _test = test; + } + + private static Journal getPanoramaPublic() + { + return JournalManager.getJournal(JournalManager.PANORAMA_PUBLIC); + } + + public static List getPrivateDatasets(Journal panoramaPublic) + { + if (panoramaPublic == null) return Collections.emptyList(); + + Set subFolders = ContainerManager.getAllChildren(panoramaPublic.getProject()); + List privateDataIds = new ArrayList<>(); + for (Container folder : subFolders) + { + ExperimentAnnotations exptAnnotations = ExperimentAnnotationsManager.getExperimentInContainer(folder); + if (exptAnnotations != null && !exptAnnotations.isPublic()) + { + privateDataIds.add(exptAnnotations.getId()); + } + } + + return privateDataIds; + } + + private static ReminderDecision getReminderDecision(@NotNull ExperimentAnnotations exptAnnotations, @NotNull PrivateDataReminderSettings settings) + { + if (exptAnnotations.isPublic()) + { + return ReminderDecision.skip("Data is already public"); + } + + if (!ExperimentAnnotationsManager.isCurrentVersion(exptAnnotations)) + { + return ReminderDecision.skip("Not the current version of the experiment"); + } + + DatasetStatus datasetStatus = DatasetStatusManager.getForExperiment(exptAnnotations); + if (datasetStatus != null) + { + if (datasetStatus.deletionRequested()) + { + return ReminderDecision.skip("Submitter has requested deletion"); + } + + if (settings.isExtensionValid(datasetStatus)) + { + return ReminderDecision.skip("Submitter requested an extension. Extension is current"); + } + + if (settings.isLastReminderRecent(datasetStatus)) + { + return ReminderDecision.skip("Recent reminder already sent"); + } + } + return reminderIsDue(exptAnnotations, settings); + } + + private static ReminderDecision reminderIsDue(ExperimentAnnotations exptAnnotations, PrivateDataReminderSettings settings) + { + LocalDate copyDate = exptAnnotations.getCreated().toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate(); + + LocalDate firstReminderDate = copyDate.plusMonths(settings.getDelayUntilFirstReminder()); + if (LocalDate.now().isBefore(firstReminderDate)) + { + return ReminderDecision.skip(String.format("First reminder not due until %s", + firstReminderDate.format(DateTimeFormatter.ofPattern(PrivateDataReminderSettings.DATE_FORMAT_PATTERN)))); + } + return ReminderDecision.post(); + } + + public static class ReminderDecision { + private final boolean shouldPost; + private final String reason; + + private ReminderDecision(boolean shouldPost, String reason) { + this.shouldPost = shouldPost; + this.reason = reason; + } + + public static ReminderDecision post() { + return new ReminderDecision(true, null); + } + + public static ReminderDecision skip(String reason) { + return new ReminderDecision(false, reason); + } + + public boolean shouldPost() { return shouldPost; } + public String getReason() { return reason; } + } + + @Override + public void run() + { + setStatus(TaskStatus.running); + + if (_panoramaPublic == null) + { + getLogger().error("Panorama Public project does not exist."); + return; + } + + postMessage(_experimentAnnotationsIds, _panoramaPublic); + + setStatus(TaskStatus.complete); + } + + private void postMessage(List expAnnotationIds, Journal panoramaPublic) + { + int total = expAnnotationIds.size(); + if (total == 0) + { + getLogger().info("No private datasets were found."); + return; + } + Logger log = getLogger(); + + ProcessingContext context = ProcessingContext.create(panoramaPublic, getUser(), _test); + if(!context.isValid()) + { + context.logErrors(log); + return; + } + ProcessingResults processingResults = new ProcessingResults(expAnnotationIds.size(), log); + + processExperiments(_experimentAnnotationsIds, context, processingResults, log); + + } + + private void processExperiments(List expAnnotationIds, ProcessingContext context, ProcessingResults processingResults, Logger log) + { + log.info(String.format("Posting reminder message to: %d message threads.", expAnnotationIds.size())); + + Set exptIds = new HashSet<>(expAnnotationIds); + try (DbScope.Transaction transaction = PanoramaPublicManager.getSchema().getScope().ensureTransaction()) + { + if (_test) + { + log.info("RUNNING IN TEST MODE - MESSAGES WILL NOT BE POSTED."); + } + for (Integer experimentAnnotationsId : exptIds) + { + processExperiment(experimentAnnotationsId, context, processingResults); + } + transaction.commit(); + } + + processingResults.logResults(log); + } + + private void processExperiment(Integer experimentAnnotationsId, ProcessingContext context, ProcessingResults processingResults) + { + ExperimentAnnotations expAnnotations = ExperimentAnnotationsManager.get(experimentAnnotationsId); + if (expAnnotations == null) + { + processingResults.addExperimentNotFound(experimentAnnotationsId); + return; + } + + ReminderDecision decision = getReminderDecision(expAnnotations, context.getSettings()); + if (!decision.shouldPost()) + { + processingResults.addSkipped(experimentAnnotationsId, decision); + return; + } + JournalSubmission submission = SubmissionManager.getSubmissionForExperiment(expAnnotations); + if (submission == null) + { + processingResults.addSubmissionNotFound(experimentAnnotationsId); + return; + } + if (submission.getLatestSubmission() == null) + { + processingResults.addLatestSubmissionNotFound(experimentAnnotationsId); + return; + } + + Container announcementsFolder = context.getAnnouncementsFolder(); + Announcement announcement = context.getAnnouncementService().getAnnouncement(announcementsFolder, getUser(), submission.getAnnouncementId()); + if (announcement == null) + { + processingResults.addAnnouncementNotFound(experimentAnnotationsId, submission, announcementsFolder); + return; + } + + User submitter = expAnnotations.getSubmitterUser(); + if (submitter == null) + { + processingResults.addSubmitterNotFound(experimentAnnotationsId); + return; + } + + if (!context.isTestMode()) + { + postReminderMessage(expAnnotations, submission, announcement, submitter, context); + + updateDatasetStatus(expAnnotations); + } + + processingResults.addProcessed(expAnnotations, announcement); + } + + private void postReminderMessage(ExperimentAnnotations expAnnotations, JournalSubmission submission, + Announcement announcement, User submitter, ProcessingContext context) + { + // Older message threads, pre March 2023, will not have the submitter or lab head on the notify list. Add them. + List notifyList = new ArrayList<>(); + notifyList.add(submitter); + if (expAnnotations.getLabHeadUser() != null) { + notifyList.add(expAnnotations.getLabHeadUser()); + } + + PanoramaPublicNotification.postPrivateDataReminderMessage( + context.getJournal(), + submission, + expAnnotations, + submitter, + context.getCurrentUser(), + notifyList, + announcement, + context.getAnnouncementsFolder(), + context.getJournalAdmin() + ); + } + + private void updateDatasetStatus(ExperimentAnnotations expAnnotations) + { + DatasetStatus datasetStatus = DatasetStatusManager.getForExperiment(expAnnotations); + if (datasetStatus == null) + { + datasetStatus = new DatasetStatus(); + datasetStatus.setExperimentAnnotationsId(expAnnotations.getId()); + datasetStatus.setLastReminderDate(new Date()); + DatasetStatusManager.save(datasetStatus, getUser()); + } + else + { + datasetStatus.setLastReminderDate(new Date()); + DatasetStatusManager.update(datasetStatus, getUser()); + } + } + + @Override + public URLHelper getStatusHref() + { + return null; + } + + @Override + public String getDescription() + { + return "Post private data reminder messages"; + } + + private static class ProcessingContext + { + private final PrivateDataReminderSettings _reminderSettings; + private final AnnouncementService _announcementService; + private final Container _announcementsFolder; + private final User _journalAdmin; + private final User _currentUser; + private final Journal _journal; + private final boolean _testMode; + + private final List _errors; + + private ProcessingContext(Builder builder) + { + _reminderSettings = builder.settings; + _announcementService = builder.announcementService; + _announcementsFolder = builder.announcementsFolder; + _journalAdmin = builder.journalAdmin; + _currentUser = builder.currentUser; + _journal = builder.journal; + _testMode = builder.testMode; + _errors = new ArrayList<>(builder.creationErrors); + } + + public PrivateDataReminderSettings getSettings() + { + return _reminderSettings; + } + public AnnouncementService getAnnouncementService() + { + return _announcementService; + } + public Container getAnnouncementsFolder() + { + return _announcementsFolder; + } + public User getJournalAdmin() + { + return _journalAdmin; + } + public User getCurrentUser() + { + return _currentUser; + } + public Journal getJournal() + { + return _journal; + } + public boolean isTestMode() + { + return _testMode; + } + + public boolean isValid() + { + return _reminderSettings != null && + _announcementService != null && + _announcementsFolder != null && + _journalAdmin != null && + _currentUser != null && + _journal != null; + } + + public void logErrors(Logger log) + { + for (String error: _errors) + { + log.error(error); + } + } + + private static class Builder + { + private PrivateDataReminderSettings settings; + private AnnouncementService announcementService; + private Container announcementsFolder; + private User journalAdmin; + private User currentUser; + private Journal journal; + private boolean testMode = false; + private final List creationErrors = new ArrayList<>(); + + public Builder withSettings(@Nullable PrivateDataReminderSettings settings) + { + this.settings = settings; + if (settings == null) + { + creationErrors.add("Could not load PrivateDataReminderSettings."); + } + return this; + } + + public Builder withAnnouncementService(@Nullable AnnouncementService announcementService) + { + this.announcementService = announcementService; + if (announcementService == null) + { + creationErrors.add("Could not get AnnouncementService."); + } + return this; + } + + public Builder withAnnouncementsFolder(@Nullable Container announcementsFolder) + { + this.announcementsFolder = announcementsFolder; + if (announcementsFolder == null) + { + creationErrors.add("Announcements folder is null - Panorama Public project does not have a support folder for messages."); + } + return this; + } + + public Builder withJournalAdmin(@Nullable User journalAdmin) + { + this.journalAdmin = journalAdmin; + if (journalAdmin == null) + { + creationErrors.add("Could not find an admin user for the Panorama Public project."); + } + return this; + } + + public Builder withCurrentUser(@Nullable User currentUser) + { + this.currentUser = currentUser; + if (currentUser == null) + { + creationErrors.add("Current user is null."); + } + return this; + } + + public Builder withJournal(@Nullable Journal journal) + { + this.journal = journal; + if (journal == null) + { + creationErrors.add("Panorama Public journal is null."); + } + return this; + } + + public Builder withTestMode(boolean testMode) + { + this.testMode = testMode; + return this; + } + + /** + * Adds a custom error message to the creation errors + */ + public Builder addError(String errorMessage) + { + if (errorMessage != null && !errorMessage.trim().isEmpty()) + { + creationErrors.add(errorMessage.trim()); + } + return this; + } + + public ProcessingContext build() + { + return new ProcessingContext(this); + } + } + public static ProcessingContext create(@NotNull Journal panoramaPublic, User user, boolean testMode) + { + Builder builder = new Builder() + .withJournal(panoramaPublic) + .withJournalAdmin(JournalManager.getJournalAdminUser(panoramaPublic)) + .withAnnouncementsFolder(panoramaPublic.getSupportContainer()) + .withCurrentUser(user) + .withTestMode(testMode) + .withSettings(PrivateDataReminderSettings.get()) + .withAnnouncementService(AnnouncementService.get()); + + return builder.build(); + } + } + + private static class ProcessingResults + { + private final List _experimentNotFound = new ArrayList<>(); + private final List _submissionNotFound = new ArrayList<>(); + private final List _announcementNotFound = new ArrayList<>(); + private final List _submitterNotFound = new ArrayList<>(); + private final List _skipped = new ArrayList<>(); + private int _processed = 0; + private final int _total; + private final Logger _log; + + public ProcessingResults(int totalExperiments, Logger log) + { + _total = totalExperiments; + _log = log; + } + + public void addExperimentNotFound(Integer experimentId) + { + _experimentNotFound.add(experimentId); + _log.error(String.format("Could not find an experiment with Id: %s.", experimentId)); + } + + public void addSubmissionNotFound(Integer experimentId) + { + _submissionNotFound.add(experimentId); + _log.error(String.format("Could not find a submission request for experiment Id: %s.", experimentId)); + } + public void addLatestSubmissionNotFound(Integer experimentId) + { + _submissionNotFound.add(experimentId); + _log.error(String.format("Submission found but latest submission is null for experiment Id: %s.", experimentId)); + } + + public void addAnnouncementNotFound(Integer experimentId, JournalSubmission submission, Container announcementsFolder) + { + _announcementNotFound.add(experimentId); + _log.error(String.format("Could not find the message thread for experiment Id: %s; announcement Id: %s in the folder %s.", + experimentId, submission.getAnnouncementId(), announcementsFolder.getPath())); + } + + public void addSubmitterNotFound(Integer experimentId) + { + _submitterNotFound.add(experimentId); + _log.error(String.format("Could not find a submitter user for experiment Id: %s.", experimentId)); + } + + public void addSkipped(Integer experimentId, ReminderDecision decision) + { + _skipped.add(experimentId); + _log.info(String.format("Skipping reminder for experiment Id %s - %s.", experimentId, decision.getReason())); + } + + public void addProcessed(ExperimentAnnotations expAnnotations, Announcement announcement) + { + _processed++; + _log.info(String.format("Experiment ID: %d; Announcement ID %d; Short URL: %s.", + expAnnotations.getId(), announcement.getRowId(), expAnnotations.getShortUrl().renderShortURL())); + _log.info(String.format("Folder: %s", PageFlowUtil.urlProvider(ProjectUrls.class).getBeginURL(expAnnotations.getContainer()).getURIString())); + _log.info(String.format("Completed: %d of %d", _processed, _total)); + } + + public void logResults(Logger log) + { + logSkipped(log); + logSummary(log); + } + + public void logSkipped(Logger log) + { + if (!_experimentNotFound.isEmpty()) + { + log.error("Experiments with the following Ids could not be found: " + + StringUtils.join(_experimentNotFound, ", ")); + } + + if (!_submissionNotFound.isEmpty()) + { + log.error("Submission requests were not found for the following experiment Ids: " + + StringUtils.join(_submissionNotFound, ", ")); + } + + if (!_announcementNotFound.isEmpty()) + { + log.error("Support message threads were not found for the following experiment Ids: " + + StringUtils.join(_announcementNotFound, ", ")); + } + + if (!_submitterNotFound.isEmpty()) + { + log.error("Submitter user was not found for the following experiment Ids: " + + StringUtils.join(_submitterNotFound, ", ")); + } + + if (!_skipped.isEmpty()) + { + log.info("The following experiments were skipped: " + + StringUtils.join(_skipped, ", ")); + } + } + + public int getTotalErrors() + { + return _experimentNotFound.size() + + _submissionNotFound.size() + + _announcementNotFound.size() + + _submitterNotFound.size(); + } + public void logSummary(Logger log) + { + if (!_skipped.isEmpty()) + { + log.info(String.format("Skipped posting reminders for %s.", StringUtilsLabKey.pluralize(_skipped.size(), "experiment"))); + } + + if (_processed > 0) + { + log.info(String.format("Successfully processed %s.", StringUtilsLabKey.pluralize(_processed, "experiment"))); + } + + log.info(String.format("Processing complete: %d total, %d processed, %d skipped, %d errors", + _total, _processed, _skipped.size(), getTotalErrors())); + } + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/query/DatasetStatusManager.java b/panoramapublic/src/org/labkey/panoramapublic/query/DatasetStatusManager.java new file mode 100644 index 00000000..c1c2ca5c --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/query/DatasetStatusManager.java @@ -0,0 +1,52 @@ +package org.labkey.panoramapublic.query; + +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableSelector; +import org.labkey.api.query.FieldKey; +import org.labkey.api.security.User; +import org.labkey.panoramapublic.PanoramaPublicManager; +import org.labkey.panoramapublic.model.DatasetStatus; +import org.labkey.panoramapublic.model.ExperimentAnnotations; + +public class DatasetStatusManager +{ + public static DatasetStatus get(int datasetStatusId) + { + return new TableSelector(PanoramaPublicManager.getTableInfoDatasetStatus(),null, null).getObject(datasetStatusId, DatasetStatus.class); + } + + public static @Nullable DatasetStatus getForExperiment(ExperimentAnnotations experimentAnnotations) + { + if (experimentAnnotations != null) + { + SimpleFilter filter = new SimpleFilter(); + filter.addCondition(FieldKey.fromParts("ExperimentAnnotationsId"), experimentAnnotations.getId()); + return new TableSelector(PanoramaPublicManager.getTableInfoDatasetStatus(), filter, null).getObject(DatasetStatus.class); + } + return null; + } + + public static void save(DatasetStatus datasetStatus, User user) + { + Table.insert(user, PanoramaPublicManager.getTableInfoDatasetStatus(), datasetStatus); + } + + public static void update(DatasetStatus datasetStatus, User user) + { + Table.update(user, PanoramaPublicManager.getTableInfoDatasetStatus(), datasetStatus, datasetStatus.getId()); + } + + public static void deleteStatusForExperiment(ExperimentAnnotations expAnnotations) + { + if (expAnnotations == null) return; + + try(DbScope.Transaction transaction = PanoramaPublicManager.getSchema().getScope().ensureTransaction()) + { + Table.delete(PanoramaPublicManager.getTableInfoDatasetStatus(), new SimpleFilter(FieldKey.fromParts("ExperimentAnnotationsId"), expAnnotations.getId())); + transaction.commit(); + } + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/query/DatasetStatusTableInfo.java b/panoramapublic/src/org/labkey/panoramapublic/query/DatasetStatusTableInfo.java new file mode 100644 index 00000000..e0413edd --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/query/DatasetStatusTableInfo.java @@ -0,0 +1,36 @@ +package org.labkey.panoramapublic.query; + +import org.jetbrains.annotations.NotNull; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.JdbcType; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.query.ExprColumn; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryForeignKey; +import org.labkey.panoramapublic.PanoramaPublicManager; +import org.labkey.panoramapublic.PanoramaPublicSchema; +import org.labkey.panoramapublic.view.publish.ShortUrlDisplayColumnFactory; + +import java.util.ArrayList; +import java.util.List; + +public class DatasetStatusTableInfo extends PanoramaPublicTable +{ + public DatasetStatusTableInfo(@NotNull PanoramaPublicSchema userSchema, ContainerFilter cf) + { + super(PanoramaPublicManager.getTableInfoDatasetStatus(), userSchema, cf, + new ContainerJoin("ExperimentAnnotationsId", PanoramaPublicManager.getTableInfoExperimentAnnotations(), "Id")); + + List visibleColumns = new ArrayList<>(); + visibleColumns.add(FieldKey.fromParts("Created")); + visibleColumns.add(FieldKey.fromParts("CreatedBy")); + visibleColumns.add(FieldKey.fromParts("Modified")); + visibleColumns.add(FieldKey.fromParts("ModifiedBy")); + visibleColumns.add(FieldKey.fromParts("ExperimentAnnotationsId", "Link")); + visibleColumns.add(FieldKey.fromParts("ExperimentAnnotationsId", "Title")); + visibleColumns.add(FieldKey.fromParts("LastReminderDate")); + visibleColumns.add(FieldKey.fromParts("ExtensionRequestedDate")); + visibleColumns.add(FieldKey.fromParts("DeletionRequestedDate")); + setDefaultVisibleColumns(visibleColumns); + } +} diff --git a/panoramapublic/src/org/labkey/panoramapublic/query/ExperimentAnnotationsManager.java b/panoramapublic/src/org/labkey/panoramapublic/query/ExperimentAnnotationsManager.java index 230369b6..b23fbe90 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/query/ExperimentAnnotationsManager.java +++ b/panoramapublic/src/org/labkey/panoramapublic/query/ExperimentAnnotationsManager.java @@ -301,6 +301,9 @@ private static void deleteExperiment(ExperimentAnnotations expAnnotations, User // Delete the Panorama Public data catalog entry for this experiment, if one exists CatalogEntryManager.deleteEntryForExperiment(expAnnotations, user); + // Delete the row in DatasetStatus for this experiment, if one exists. + DatasetStatusManager.deleteStatusForExperiment(expAnnotations); + Table.delete(PanoramaPublicManager.getTableInfoExperimentAnnotations(), expAnnotations.getId()); if(expAnnotations.isJournalCopy() && expAnnotations.getShortUrl() != null) diff --git a/panoramapublic/src/org/labkey/panoramapublic/query/ExperimentAnnotationsTableInfo.java b/panoramapublic/src/org/labkey/panoramapublic/query/ExperimentAnnotationsTableInfo.java index b743b15e..bf530acc 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/query/ExperimentAnnotationsTableInfo.java +++ b/panoramapublic/src/org/labkey/panoramapublic/query/ExperimentAnnotationsTableInfo.java @@ -49,6 +49,7 @@ import org.labkey.api.security.User; import org.labkey.api.security.UserManager; import org.labkey.api.security.UserPrincipal; +import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.Permission; import org.labkey.api.security.roles.FolderAdminRole; import org.labkey.api.security.roles.ProjectAdminRole; @@ -75,8 +76,10 @@ import org.labkey.panoramapublic.PanoramaPublicSchema; import org.labkey.panoramapublic.model.CatalogEntry; import org.labkey.panoramapublic.model.DataLicense; +import org.labkey.panoramapublic.model.DatasetStatus; import org.labkey.panoramapublic.model.ExperimentAnnotations; import org.labkey.panoramapublic.model.Journal; +import org.labkey.panoramapublic.security.PanoramaPublicSubmitterPermission; import org.labkey.panoramapublic.view.publish.CatalogEntryWebPart; import org.labkey.panoramapublic.view.publish.ShortUrlDisplayColumnFactory; @@ -322,6 +325,8 @@ public Class getDisplayValueClass() catalogEntryCol.setDisplayColumnFactory(CatalogEntryIconColumn::new); addColumn(catalogEntryCol); + addColumn(getDatasetStatusCol(cf)); + List visibleColumns = new ArrayList<>(); visibleColumns.add(FieldKey.fromParts("Share")); visibleColumns.add(FieldKey.fromParts("Title")); @@ -404,6 +409,23 @@ private ExprColumn getCatalogEntryCol() return col; } + private ExprColumn getDatasetStatusCol(ContainerFilter cf) + { + SQLFragment datasetStatusSql = new SQLFragment(" (SELECT status.Id AS DatasetStatus ") + .append(" FROM ").append(PanoramaPublicManager.getTableInfoDatasetStatus(), "status") + .append(" WHERE ") + .append(" status.experimentAnnotationsId = ").append(ExprColumn.STR_TABLE_ALIAS).append(".Id") + .append(") "); + ExprColumn col = new ExprColumn(this, "DatasetStatus", datasetStatusSql, JdbcType.INTEGER); + col.setDescription("Dataset Status"); + col.setDisplayColumnFactory(DatasetStatusColumn::new); + + col.setFk(QueryForeignKey + .from(getUserSchema(), cf) + .to(PanoramaPublicSchema.TABLE_DATASET_STATUS, "Id", null)); + return col; + } + private ExprColumn getIsPublicCol() { // Panorama Public dataset folders do not inherit permissions from the parent folder, so we don't need to worry about that case. @@ -829,7 +851,7 @@ public void renderGridCellContents(RenderContext ctx, HtmlWriter out) if (experimentId != null) { ExperimentAnnotations expAnnot = ExperimentAnnotationsManager.get(experimentId); - // Display the catalog entry link only if the user has the required permissions (Admin or PanoramaPublicSubmitter) in the the experiment folder. + // Display the catalog entry link only if the user has the required permissions (Admin or PanoramaPublicSubmitter) in the experiment folder. if (expAnnot != null && CatalogEntryWebPart.canBeDisplayed(expAnnot, user)) { CatalogEntry entry = catalogEntryId == null ? null : CatalogEntryManager.get(catalogEntryId); @@ -851,4 +873,71 @@ public void renderGridCellContents(RenderContext ctx, HtmlWriter out) out.write(HtmlString.NBSP); } } + + public static class DatasetStatusColumn extends DataColumn + { + private final FieldKey EXPT_ANNOTATIONS_ID_COL = new FieldKey(getColumnInfo().getFieldKey(), "experimentAnnotationsId"); + + public DatasetStatusColumn(ColumnInfo col) + { + super(col); + super.setCaption("Dataset Status"); + } + + @Override + public Object getDisplayValue(RenderContext ctx) + { + User user = ctx.getViewContext().getUser(); + if (user == null || user.isGuest()) + { + return ""; + } + Integer statusId = ctx.get(getColumnInfo().getFieldKey(), Integer.class); + + // Get the experiment connected with this status Id. + Integer experimentId = ctx.get(EXPT_ANNOTATIONS_ID_COL, Integer.class); + if (experimentId != null) + { + ExperimentAnnotations expAnnot = ExperimentAnnotationsManager.get(experimentId); + boolean userHasPermissions = expAnnot != null + && expAnnot.getContainer().hasOneOf(user, Set.of(AdminPermission.class, PanoramaPublicSubmitterPermission.class)); + if (userHasPermissions) + { + DatasetStatus status = statusId == null ? null : DatasetStatusManager.get(statusId); + if (status == null) + { + return statusId == null ? "" : "NOT FOUND; ID: " + statusId; + } + String displayStr = status.deletionRequested() + ? "Deletion Requested" + : status.extensionRequested() + ? "Extension Requested" + : status.reminderSent() + ? "Reminder Sent" + : ""; + return displayStr; + } + } + return ""; + } + + @Override + public void addQueryFieldKeys(Set keys) + { + super.addQueryFieldKeys(keys); + keys.add(EXPT_ANNOTATIONS_ID_COL); + } + + @Override + public boolean isSortable() + { + return false; + } + + @Override + public boolean isFilterable() + { + return false; + } + } } diff --git a/panoramapublic/src/org/labkey/panoramapublic/view/createMessageForm.jsp b/panoramapublic/src/org/labkey/panoramapublic/view/createMessageForm.jsp index a2b1f056..b4791b4d 100644 --- a/panoramapublic/src/org/labkey/panoramapublic/view/createMessageForm.jsp +++ b/panoramapublic/src/org/labkey/panoramapublic/view/createMessageForm.jsp @@ -28,7 +28,7 @@ function submitForm() { const form = document.getElementById("panorama-public-message-form"); - let dataRegion = LABKEY.DataRegions['ExperimentAnnotationsTable']; + let dataRegion = LABKEY.DataRegions['ExperimentAnnotations']; let selectedRowIds = dataRegion.getChecked(); // console.log("Selection count: " + selectedRowIds.length); let selected = ""; diff --git a/panoramapublic/src/org/labkey/panoramapublic/view/privateDataRemindersSettingsForm.jsp b/panoramapublic/src/org/labkey/panoramapublic/view/privateDataRemindersSettingsForm.jsp new file mode 100644 index 00000000..af474fb9 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/view/privateDataRemindersSettingsForm.jsp @@ -0,0 +1,154 @@ +<% + /* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +%> +<%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> +<%@ page import="org.labkey.api.view.HttpView" %> +<%@ page import="org.labkey.api.view.JspView" %> +<%@ page import="org.labkey.api.view.template.ClientDependencies" %> +<%@ page import="org.labkey.panoramapublic.message.PrivateDataReminderSettings" %> +<%@ page import="org.labkey.api.view.ActionURL" %> +<%@ page import="org.labkey.panoramapublic.PanoramaPublicController.PrivateDataReminderSettingsForm" %> +<%@ page import="org.labkey.panoramapublic.PanoramaPublicController.PanoramaPublicAdminViewAction" %> +<%@ page import="org.labkey.panoramapublic.query.JournalManager" %> +<%@ page import="org.labkey.panoramapublic.model.Journal" %> +<%@ page import="java.util.List" %> +<%@ page import="org.labkey.panoramapublic.PanoramaPublicController.PrivateDataReminderSettingsAction" %> +<%@ page import="org.labkey.panoramapublic.message.PrivateDataReminderSettings" %> +<%@ page extends="org.labkey.api.jsp.JspBase" %> + +<%! + @Override + public void addClientDependencies(ClientDependencies dependencies) + { + dependencies.add("Ext4"); + } +%> +<% + JspView view = HttpView.currentView(); + var form = view.getModelBean(); + ActionURL panoramaPublicAdminUrl = urlFor(PanoramaPublicAdminViewAction.class); + + List journals = JournalManager.getJournals(); + +%> + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ <%=h(PrivateDataReminderSettings.PROP_ENABLE_REMINDER)%> + + /> +
+ <%=h(PrivateDataReminderSettings.PROP_REMINDER_TIME)%> + + +
+ Reminders will be sent daily at the specified time (e.g. <%=h(PrivateDataReminderSettings.DEFAULT_REMINDER_TIME)%>), if enabled. +
+
+ <%=h(PrivateDataReminderSettings.PROP_DELAY_UNTIL_FIRST_REMINDER)%> + + +
+ Number of months after data submission before sending the first reminder. +
+ Entering 0 will send a reminder the next time the job runs. +
+
+ <%=h(PrivateDataReminderSettings.PROP_REMINDER_FREQUENCY)%> + + +
+ Interval in months between reminder messages after the first one. +
+ Entering 0 will send a reminder the next time the job runs. +
+
+ <%=h(PrivateDataReminderSettings.PROP_EXTENSION_LENGTH)%> + + +
+ Number of months the private status of a dataset can be extended at the submitter's request. +
+
+ <%=button("Save").submit(true)%> + <%=button("Cancel").href(panoramaPublicAdminUrl)%> +
+
+ : + + <%=link("Send Reminders Now").onClick("clickSendRemindersLink();").build()%> +
+
+ +
\ No newline at end of file diff --git a/panoramapublic/src/org/labkey/panoramapublic/view/sendPrivateDataRemindersForm.jsp b/panoramapublic/src/org/labkey/panoramapublic/view/sendPrivateDataRemindersForm.jsp new file mode 100644 index 00000000..5708b3c5 --- /dev/null +++ b/panoramapublic/src/org/labkey/panoramapublic/view/sendPrivateDataRemindersForm.jsp @@ -0,0 +1,78 @@ +<% + /* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +%> +<%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> +<%@ page import="org.labkey.api.view.HttpView" %> +<%@ page import="org.labkey.api.view.JspView" %> +<%@ page import="org.labkey.panoramapublic.PanoramaPublicController" %> +<%@ page import="org.labkey.api.view.template.ClientDependencies" %> +<%@ page extends="org.labkey.api.jsp.JspBase" %> + +<%! + @Override + public void addClientDependencies(ClientDependencies dependencies) + { + dependencies.add("Ext4"); + } +%> +<% + JspView view = HttpView.currentView(); + var form = view.getModelBean(); +%> + + + + + +
+
+ A reminder message will be sent to the submitters of the selected experiments. +
+ + + + + + + + + +
Test Mode: + /> +
<%=button("Post Reminders").onClick("submitForm();")%>
+
+
+
\ No newline at end of file diff --git a/panoramapublic/test/src/org/labkey/test/tests/panoramapublic/PanoramaPublicBaseTest.java b/panoramapublic/test/src/org/labkey/test/tests/panoramapublic/PanoramaPublicBaseTest.java index 8960bcdf..a7da563a 100644 --- a/panoramapublic/test/src/org/labkey/test/tests/panoramapublic/PanoramaPublicBaseTest.java +++ b/panoramapublic/test/src/org/labkey/test/tests/panoramapublic/PanoramaPublicBaseTest.java @@ -586,6 +586,22 @@ protected void enableCatalogEntries() waitForText("Panorama Public catalog entry settings were saved"); } + protected void verifyIsPublicColumn(String panoramaPublicProject, String experimentTitle, boolean isPublic) + { + if (isImpersonating()) + { + stopImpersonating(true); + } + goToProjectHome(panoramaPublicProject); + + DataRegionTable expListTable = DataRegionTable.findDataRegionWithinWebpart(this, "Targeted MS Experiment List"); + expListTable.ensureColumnsPresent("Title", "DataVersion", "Public"); + expListTable.setFilter("Title", "Equals", experimentTitle); + expListTable.setFilter("DataVersion", "Equals", "1"); + assertEquals(1, expListTable.getDataRowCount()); + assertEquals(isPublic ? "Yes" : "No", expListTable.getDataAsText(0, "Public")); + } + @Override protected void doCleanup(boolean afterTest) throws TestTimeoutException { diff --git a/panoramapublic/test/src/org/labkey/test/tests/panoramapublic/PanoramaPublicMakePublicTest.java b/panoramapublic/test/src/org/labkey/test/tests/panoramapublic/PanoramaPublicMakePublicTest.java index f81de6b8..4abe965b 100644 --- a/panoramapublic/test/src/org/labkey/test/tests/panoramapublic/PanoramaPublicMakePublicTest.java +++ b/panoramapublic/test/src/org/labkey/test/tests/panoramapublic/PanoramaPublicMakePublicTest.java @@ -13,7 +13,6 @@ import org.labkey.test.components.panoramapublic.TargetedMsExperimentWebPart; import org.labkey.test.pages.admin.PermissionsPage; import org.labkey.test.util.ApiPermissionsHelper; -import org.labkey.test.util.DataRegionTable; import org.labkey.test.util.Ext4Helper; import org.labkey.test.util.PermissionsHelper; import org.openqa.selenium.NoSuchElementException; @@ -227,22 +226,6 @@ private void verifyPermissions(String userProject, String userFolder, String pan } } - private void verifyIsPublicColumn(String panoramaPublicProject, String experimentTitle, boolean isPublic) - { - if (isImpersonating()) - { - stopImpersonating(true); - } - goToProjectHome(panoramaPublicProject); - - DataRegionTable expListTable = DataRegionTable.findDataRegionWithinWebpart(this, "Targeted MS Experiment List"); - expListTable.ensureColumnsPresent("Title", "DataVersion", "Public"); - expListTable.setFilter("Title", "Equals", experimentTitle); - expListTable.setFilter("DataVersion", "Equals", "1"); - assertEquals(1, expListTable.getDataRowCount()); - assertEquals(isPublic ? "Yes" : "No", expListTable.getDataAsText(0, "Public")); - } - private String getReviewerEmail(String panoramaPublicProject, String panoramaPublicFolder) { // Get the reviewer's email from the notification messages diff --git a/panoramapublic/test/src/org/labkey/test/tests/panoramapublic/PrivateDataReminderTest.java b/panoramapublic/test/src/org/labkey/test/tests/panoramapublic/PrivateDataReminderTest.java new file mode 100644 index 00000000..3619b747 --- /dev/null +++ b/panoramapublic/test/src/org/labkey/test/tests/panoramapublic/PrivateDataReminderTest.java @@ -0,0 +1,453 @@ +package org.labkey.test.tests.panoramapublic; + +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.labkey.test.BaseWebDriverTest; +import org.labkey.test.Locator; +import org.labkey.test.TestTimeoutException; +import org.labkey.test.categories.External; +import org.labkey.test.categories.MacCossLabModules; +import org.labkey.test.pages.LabkeyErrorPage; +import org.labkey.test.util.ApiPermissionsHelper; +import org.labkey.test.util.DataRegionTable; + +import java.util.List; + +import static org.junit.Assert.assertEquals; + +@Category({External.class, MacCossLabModules.class}) +@BaseWebDriverTest.ClassTimeout(minutes = 7) +public class PrivateDataReminderTest extends PanoramaPublicBaseTest +{ + private static final String SKY_FILE_1 = "MRMer.zip"; + + static final String SUBMITTER_1 = "submitter_1@panoramapublic.test"; + static final String SUBMITTER_2 = "submitter_2@panoramapublic.test"; + static final String SUBMITTER_3 = "submitter_3@panoramapublic.test"; + private static final String ADMIN_1 = "admin_1@panoramapublic.test"; + private static final String ADMIN_2 = "admin_2@panoramapublic.test"; + private static final String ADMIN_3 = "admin_3@panoramapublic.test"; + + private static final String REMINDER_MESSAGE_TITLE = "Title: Action Required: Status Update for Your Private Data on Panorama Public"; + private static final String EXTENSION_MESSAGE_TITLE = "Title: Private Status Extended - "; + private static final String DELETION_MESSAGE_TITLE = "Title: Data Deletion Requested - "; + private static final String MESSAGE_PAGE_TITLE = "Submitted - "; + + + @Test + public void testPrivateDataReminder() + { + String panoramaPublicProject = PANORAMA_PUBLIC; + goToProjectHome(panoramaPublicProject); + ApiPermissionsHelper permissionsHelper = new ApiPermissionsHelper(this); + permissionsHelper.setSiteGroupPermissions("Guests", "Reader"); + + + String testProject = getProjectName(); + + DataFolderInfo folderInfo_1 = createAndSubmitFolder(testProject, + "Private Data 1", + panoramaPublicProject, + "Private Data 1 Copy", + "Test for private data message reminder - DATA ONE", + SUBMITTER_1, "One", + ADMIN_1); + + DataFolderInfo folderInfo_2 = createAndSubmitFolder(testProject, + "Private Data 2", + panoramaPublicProject, + "Private Data 2 Copy", + "Test for private data message reminder - DATA TWO", + SUBMITTER_2, "Two", + ADMIN_2); + + DataFolderInfo folderInfo_3 = createAndSubmitFolder(testProject, + "Private Data 3", + panoramaPublicProject, + "Private Data 3 Copy", + "Test for private data message reminder - DATA THREE", + SUBMITTER_3, "Three", + ADMIN_3); + + log("Making data 3 public."); + makePublic(panoramaPublicProject, folderInfo_3); + folderInfo_3.setPublic(true); + + log("Verifying data 1 is private"); + verifyIsPublicColumn(panoramaPublicProject, folderInfo_1.getExperimentTitle(), false); + log("Verifying data 2 is private"); + verifyIsPublicColumn(panoramaPublicProject, folderInfo_2.getExperimentTitle(), false); + log("Verifying data 3 is public"); + verifyIsPublicColumn(panoramaPublicProject, folderInfo_3.getExperimentTitle(), true); + + List dataFolderInfos = List.of(folderInfo_1, folderInfo_2, folderInfo_3); + testSendingReminders(panoramaPublicProject, dataFolderInfos); + } + + private DataFolderInfo createAndSubmitFolder(String testProject, String sourceFolder, + String panoramaPublicProject, String targetFolder, + String experimentTitle, + String submitter, String submitterName, String admin) + { + log(String.format("Creating folder '%s' and copying to Panorama Public folder '%s'.", sourceFolder, targetFolder)); + String shortAccessUrl = setupFolderSubmitAndCopy(testProject, sourceFolder, targetFolder, experimentTitle, + submitter, submitterName, admin, SKY_FILE_1); + goToProjectFolder(panoramaPublicProject, targetFolder); + goToExperimentDetailsPage(); + int exptAnnotationsId = Integer.parseInt(portalHelper.getUrlParam("id")); + return new DataFolderInfo(sourceFolder, targetFolder, shortAccessUrl, experimentTitle, exptAnnotationsId, submitter); + } + + private void gotoSupportMessage(DataFolderInfo folderInfo) + { + portalHelper.clickWebpartMenuItem("Targeted MS Experiment", true, "Support Messages"); + waitForText("Submitted - " + folderInfo.getShortUrl()); + } + + private void makePublic(String projectName, DataFolderInfo folderInfo) + { + if (isImpersonating()) + { + stopImpersonating(true); + } + goToProjectFolder(projectName, folderInfo.getTargetFolder()); + impersonate(folderInfo.getSubmitter()); + goToDashboard(); + makeDataPublic(true); + stopImpersonating(); + } + + private void testSendingReminders(String projectName, List dataFolderInfos) + { + goToProjectHome(projectName); + goToDataPipeline(); + int pipelineJobCount = getPipelineStatusValues().size(); + + List privateData = dataFolderInfos.stream().filter(f -> !f.isPublic()).toList(); + int privateDataCount = privateData.size(); + assertEquals(2, privateDataCount); + + log("Changing reminder settings. Setting reminder frequency to 0."); + saveSettings("2", "12", "0"); + + // Do not select any experiments. Job will not run. + log("Attempt to send reminders without selecting any experiments. Job should not run."); + postRemindersNoExperimentsSelected(projectName, privateDataCount); + + // Post reminders. None should get posted since the delayUntilFirstReminder is set to 12 months. + log("Posting reminders. Select all experiment rows."); + postReminders(projectName, false, privateDataCount, -1, ++pipelineJobCount); + // Verify that no reminders posted + verifyNoReminderPosted(projectName, dataFolderInfos); + String message = String.format("Skipping reminder for experiment Id %d - First reminder not due until ", privateData.get(0).getExperimentAnnotationsId()); + String message2 = String.format("Skipping reminder for experiment Id %d - First reminder not due until ", privateData.get(1).getExperimentAnnotationsId()); + verifyPipelineJobLogMessage(projectName, message, message2, "Skipped posting reminders for 2 experiments"); + + log("Changing reminder settings. Setting delay until first reminder to 0."); + saveSettings("2", "0", "0"); + + // Post reminders in test mode. + log("Posting reminders in test mode. Select all experiment rows."); + postRemindersInTestMode(projectName, privateDataCount, -1, ++pipelineJobCount); + // Verify that no reminders posted since test mode was checked + verifyNoReminderPosted(projectName, dataFolderInfos); + + // Now really post the reminder. Select only the first experiment. + postReminders(projectName, false, privateDataCount, 1, ++pipelineJobCount); + verifyReminderPosted(projectName, privateData.get(0), 1); // Reminder should be posted only to the selected experiment + verifyNoReminderPosted(projectName, privateData.get(1)); // No reminder on the second experiment, since it was not selected + verifyNoReminderPosted(projectName, dataFolderInfos.get(2)); // No reminder since this is public data + + // Change the reminder frequency to 1. + log("Changing reminder settings. Setting reminder frequency to 1."); + saveSettings("2", "0", "1"); + // Post reminders again. Since reminder frequency is set to 1, no reminders will be posted to the first data. + postReminders(projectName, false, privateDataCount, -1, ++pipelineJobCount); + verifyReminderPosted(projectName, privateData.get(0), 1); // No new reminders since reminder frequency is set to 1. + verifyReminderPosted(projectName, privateData.get(1), 1); + message = String.format("Skipping reminder for experiment Id %d - Recent reminder already sent", privateData.get(0).getExperimentAnnotationsId()); + verifyPipelineJobLogMessage(projectName, message, "Skipped posting reminders for 1 experiment"); + + // Change reminder frequency to 0 again. + log("Changing reminder settings. Setting reminder frequency to 0."); + saveSettings("2", "0", "0"); + + // Request extension for the first experiment. + log("Requesting extension for experiment Id " + privateData.get(0).getExperimentAnnotationsId()); + requestExtension(projectName, privateData.get(0)); + postReminders(projectName, false, privateDataCount, -1, ++pipelineJobCount); + verifyReminderPosted(projectName, privateData.get(0),1); // No new reminders since extension requested. + verifyReminderPosted(projectName, privateData.get(1), 2); // Reminder posted since reminder frequency is 0. + message = String.format("Skipping reminder for experiment Id %d - Submitter requested an extension. Extension is current", + privateData.get(0).getExperimentAnnotationsId()); + verifyPipelineJobLogMessage(projectName, message, "Skipped posting reminders for 1 experiment"); + + // Request deletion for the second experiment. + log("Requesting deletion for experiment Id " + privateData.get(1).getExperimentAnnotationsId()); + requestDeletion(projectName, privateData.get(1)); + // Post reminders again - none should be posted + postReminders(projectName, false, privateDataCount, -1, ++pipelineJobCount); + verifyReminderPosted(projectName, privateData.get(0),1); // No new reminders since extension requested. + verifyReminderPosted(projectName, privateData.get(1), 2); // No new reminders since deletion requested. + message2 = String.format("Skipping reminder for experiment Id %d - Submitter has requested deletion", privateData.get(1).getExperimentAnnotationsId()); + verifyPipelineJobLogMessage(projectName, message, message2, "Skipped posting reminders for 2 experiments"); + } + + private void requestExtension(String projectName, DataFolderInfo folderInfo) + { + goToProjectFolder(projectName, folderInfo.getTargetFolder()); + gotoSupportMessage(folderInfo); + + String actionName = "Request Extension"; + checkUnauthorizedAccess(actionName); + + goToProjectFolder(projectName, folderInfo.getTargetFolder()); + gotoSupportMessage(folderInfo); + impersonate(folderInfo.getSubmitter()); + + assertTextPresent(actionName); + click(Locator.linkWithText(actionName)); + waitForText("Request Extension For Panorama Public Data"); + assertTextPresent("You are requesting an extension for the private data on Panorama Public at " + folderInfo.getShortUrl()); + clickButton("OK", 0); + waitForText("An extension request was successfully submitted for the data at " + folderInfo.getShortUrl()); + + stopImpersonating(); + goToProjectFolder(projectName, folderInfo.getTargetFolder()); + gotoSupportMessage(folderInfo); + assertTextPresent(EXTENSION_MESSAGE_TITLE + folderInfo.getShortUrl()); + } + + private void requestDeletion(String projectName, DataFolderInfo folderInfo) + { + goToProjectFolder(projectName, folderInfo.getTargetFolder()); + gotoSupportMessage(folderInfo); + + String actionName = "Request Deletion"; + checkUnauthorizedAccess(actionName); + + goToProjectFolder(projectName, folderInfo.getTargetFolder()); + gotoSupportMessage(folderInfo); + impersonate(folderInfo.getSubmitter()); + + assertTextPresent(actionName); + click(Locator.linkWithText(actionName)); + waitForText("Request Deletion For Panorama Public Data"); + assertTextPresent("You are requesting deletion of the private data on Panorama Public at " + folderInfo.getShortUrl()); + clickButton("OK", 0); + waitForText("A deletion request was successfully submitted for the data at " + folderInfo.getShortUrl()); + + stopImpersonating(); + goToProjectFolder(projectName, folderInfo.getTargetFolder()); + gotoSupportMessage(folderInfo); + assertTextPresent(DELETION_MESSAGE_TITLE + folderInfo.getShortUrl()); + } + + private void checkUnauthorizedAccess(String actionName) + { + impersonate(SUBMITTER_3); // This submitter does not have access + waitForText(actionName); + click(Locator.linkWithText(actionName)); + new LabkeyErrorPage(getDriver()).assertUnauthorized(checker()); + stopImpersonating(false); // Don't go home + waitForText(actionName); + } + + private void verifyPipelineJobLogMessage(String project, String... message) + { + goToProjectHome(project); + goToDataPipeline(); + goToDataPipeline().clickStatusLink(0); + assertTextPresent(message); + } + + private void verifyNoReminderPosted(String projectName, List folderInfos) + { + for (DataFolderInfo folderInfo: folderInfos) + { + verifyNoReminderPosted(projectName, folderInfo); + } + } + + private void verifyNoReminderPosted(String projectName, DataFolderInfo folderInfo) + { + verifyReminderPosted(projectName, folderInfo, 0); + } + + private void verifyReminderPosted(String projectName, DataFolderInfo folderInfo, int count) + { + goToProjectFolder(projectName, folderInfo.getTargetFolder()); + gotoSupportMessage(folderInfo); + waitForText(MESSAGE_PAGE_TITLE + folderInfo.getShortUrl()); + if (count == 0) + { + assertTextNotPresent(REMINDER_MESSAGE_TITLE); + } + else + { + assertTextPresent(REMINDER_MESSAGE_TITLE, 1); + assertTextPresent("Is the paper associated with this work already published?", count); + } + } + + private void saveSettings(String extensionLength, String delayUntilFirstReminder, String reminderFrequency) + { + goToAdminConsole().goToSettingsSection(); + clickAndWait(Locator.linkWithText("Panorama Public")); + clickAndWait(Locator.linkWithText("Private Data Reminder Settings")); + + setFormElement(Locator.input("delayUntilFirstReminder"), delayUntilFirstReminder); + setFormElement(Locator.input("reminderFrequency"), reminderFrequency); + setFormElement(Locator.input("extensionLength"), extensionLength); + clickButton("Save", 0); + waitForText("Private data reminder settings saved"); + clickAndWait(Locator.linkWithText("Back to Private Data Reminder Settings")); + + // clickAndWait(Locator.linkWithText("Private Data Reminder Settings")); + assertEquals(String.valueOf(delayUntilFirstReminder), getFormElement(Locator.input("delayUntilFirstReminder"))); + assertEquals(String.valueOf(reminderFrequency), getFormElement(Locator.input("reminderFrequency"))); + assertEquals(String.valueOf(extensionLength), getFormElement(Locator.input("extensionLength"))); + } + + private void postRemindersNoExperimentsSelected(String projectName, int expectedExperimentCount) + { + postReminders(projectName, true, expectedExperimentCount, 0, 0); + } + + private void postRemindersInTestMode(String projectName, int expectedExperimentCount, int selectExperimentCount, int pipelineJobCount) + { + postReminders(projectName, true, expectedExperimentCount, selectExperimentCount, pipelineJobCount); + } + + + private void postReminders(String projectName, boolean testMode, int expectedExperimentCount, int selectRowCount, int pipelineJobCount) + { + goToSendRemindersPage(projectName); + + DataRegionTable table = new DataRegionTable("ExperimentAnnotations", getDriver()); + assertEquals(expectedExperimentCount, table.getDataRowCount()); + + table.clearAllFilters(); + table.uncheckAllOnPage(); + + if (selectRowCount == -1) + { + table.checkAllOnPage(); + } + else + { + for(int i = 0; i < selectRowCount; i++) + { + table.checkCheckbox(i); + } + } + + if (testMode) + { + checkCheckbox(Locator.checkboxByName("testMode")); + } + + clickButton("Post Reminders", 0); + + if (selectRowCount == 0) + { + waitForText("Please select at least one experiment"); + if (testMode) + { + assertChecked(Locator.checkboxByName("testMode")); + } + } + else + { + waitForPipelineJobsToComplete(pipelineJobCount, "Post private data reminder messages", false); + goToDataPipeline().clickStatusLink(0); + if (testMode) + { + assertTextPresent("RUNNING IN TEST MODE - MESSAGES WILL NOT BE POSTED."); + } + else + { + assertTextNotPresent("RUNNING IN TEST MODE - MESSAGES WILL NOT BE POSTED."); + } + } + } + + private void goToSendRemindersPage(String projectName) + { + goToAdminConsole().goToSettingsSection(); + clickAndWait(Locator.linkWithText("Panorama Public")); + clickAndWait(Locator.linkWithText("Private Data Reminder Settings")); + selectOptionByText(Locator.name("journal"), projectName); + clickAndWait(Locator.linkWithText("Send Reminders Now")); + waitForText(projectName, "A reminder message will be sent to the submitters of the selected experiments"); + } + + @Override + protected void doCleanup(boolean afterTest) throws TestTimeoutException + { + _userHelper.deleteUsers(false,SUBMITTER_1, SUBMITTER_2, SUBMITTER_3, ADMIN_1, ADMIN_2, ADMIN_3); + + super.doCleanup(afterTest); + } + + private static class DataFolderInfo + { + private final String _sourceFolder; + private final String _targetFolder; + private final String _shortUrl; + private final String _experimentTitle; + private final int _experimentAnnotationsId; + private final String _submitter; + private boolean _isPublic = false; + + public DataFolderInfo(String sourceFolder, String targetFolder, String shortUrl, String experimentTitle, int experimentAnnotationsId, String submitter) + { + _sourceFolder = sourceFolder; + _targetFolder = targetFolder; + _shortUrl = shortUrl; + _experimentTitle = experimentTitle; + _experimentAnnotationsId = experimentAnnotationsId; + _submitter = submitter; + } + + public String getSourceFolder() + { + return _sourceFolder; + } + + public String getTargetFolder() + { + return _targetFolder; + } + + public String getShortUrl() + { + return _shortUrl; + } + + public String getExperimentTitle() + { + return _experimentTitle; + } + + public int getExperimentAnnotationsId() + { + return _experimentAnnotationsId; + } + + public String getSubmitter() + { + return _submitter; + } + + public boolean isPublic() + { + return _isPublic; + } + + public void setPublic(boolean isPublic) + { + _isPublic = isPublic; + } + } +}