diff --git a/conf/solr/9.3.0/schema.xml b/conf/solr/9.3.0/schema.xml
index 90e9287d659..521e7a7db72 100644
--- a/conf/solr/9.3.0/schema.xml
+++ b/conf/solr/9.3.0/schema.xml
@@ -157,7 +157,8 @@
-
+
+
diff --git a/doc/release-notes/9375-retention-period.md b/doc/release-notes/9375-retention-period.md
new file mode 100644
index 00000000000..a088cabf138
--- /dev/null
+++ b/doc/release-notes/9375-retention-period.md
@@ -0,0 +1,8 @@
+The Dataverse Software now supports file-level retention periods. The ability to set retention periods, with a minimum duration (in months), can be configured by a Dataverse installation administrator. For more information, see the [Retention Periods section](https://guides.dataverse.org/en/6.3/user/dataset-management.html#retention-periods) of the Dataverse Software Guides.
+
+- Users can configure a specific retention period, defined by an end date and a short reason, on a set of selected files or an individual file, by selecting the 'Retention Period' menu item and entering information in a popup dialog. Retention Periods can only be set, changed, or removed before a file has been published. After publication, only Dataverse installation administrators can make changes, using an API.
+
+- After the retention period expires, files can not be previewed or downloaded (as if restricted, with no option to allow access requests). The file (landing) page and all the metadata remains available.
+
+
+Release notes should mention that a Solr schema update is needed.
diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst
index afcf327d233..bcc37d6db1c 100644
--- a/doc/sphinx-guides/source/api/native-api.rst
+++ b/doc/sphinx-guides/source/api/native-api.rst
@@ -1228,6 +1228,7 @@ File access filtering is also optionally supported. In particular, by the follow
* ``Restricted``
* ``EmbargoedThenRestricted``
* ``EmbargoedThenPublic``
+* ``RetentionPeriodExpired``
If no filter is specified, the files will match all of the above categories.
@@ -1277,7 +1278,7 @@ The returned file counts are based on different criteria:
- Per content type
- Per category name
- Per tabular tag name
-- Per access status (Possible values: Public, Restricted, EmbargoedThenRestricted, EmbargoedThenPublic)
+- Per access status (Possible values: Public, Restricted, EmbargoedThenRestricted, EmbargoedThenPublic, RetentionPeriodExpired)
.. code-block:: bash
@@ -1331,6 +1332,7 @@ File access filtering is also optionally supported. In particular, by the follow
* ``Restricted``
* ``EmbargoedThenRestricted``
* ``EmbargoedThenPublic``
+* ``RetentionPeriodExpired``
If no filter is specified, the files will match all of the above categories.
@@ -2146,6 +2148,7 @@ File access filtering is also optionally supported. In particular, by the follow
* ``Restricted``
* ``EmbargoedThenRestricted``
* ``EmbargoedThenPublic``
+* ``RetentionPeriodExpired``
If no filter is specified, the files will match all of the above categories.
@@ -2583,7 +2586,38 @@ The API call requires a Json body that includes the list of the fileIds that the
export JSON='{"fileIds":[300,301]}'
curl -H "X-Dataverse-key: $API_TOKEN" -H "Content-Type:application/json" "$SERVER_URL/api/datasets/:persistentId/files/actions/:unset-embargo?persistentId=$PERSISTENT_IDENTIFIER" -d "$JSON"
-
+
+Set a Retention Period on Files in a Dataset
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``/api/datasets/$dataset-id/files/actions/:set-retention`` can be used to set a retention period on one or more files in a dataset. Retention periods can be set on files that are only in a draft dataset version (and are not in any previously published version) by anyone who can edit the dataset. The same API call can be used by a superuser to add a retention period to files that have already been released as part of a previously published dataset version.
+
+The API call requires a Json body that includes the retention period's end date (dateUnavailable), a short reason (optional), and a list of the fileIds that the retention period should be set on. The dateUnavailable must be after the current date and the duration (dateUnavailable - today's date) must be larger than the value specified by the :ref:`:MinRetentionDurationInMonths` setting. All files listed must be in the specified dataset. For example:
+
+.. code-block:: bash
+
+ export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
+ export SERVER_URL=https://demo.dataverse.org
+ export PERSISTENT_IDENTIFIER=doi:10.5072/FK2/7U7YBV
+ export JSON='{"dateUnavailable":"2051-12-31", "reason":"Standard project retention period", "fileIds":[300,301,302]}'
+
+ curl -H "X-Dataverse-key: $API_TOKEN" -H "Content-Type:application/json" "$SERVER_URL/api/datasets/:persistentId/files/actions/:set-retention?persistentId=$PERSISTENT_IDENTIFIER" -d "$JSON"
+
+Remove a Retention Period on Files in a Dataset
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``/api/datasets/$dataset-id/files/actions/:unset-retention`` can be used to remove a retention period on one or more files in a dataset. Retention periods can be removed from files that are only in a draft dataset version (and are not in any previously published version) by anyone who can edit the dataset. The same API call can be used by a superuser to remove retention periods from files that have already been released as part of a previously published dataset version.
+
+The API call requires a Json body that includes the list of the fileIds that the retention period should be removed from. All files listed must be in the specified dataset. For example:
+
+.. code-block:: bash
+
+ export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
+ export SERVER_URL=https://demo.dataverse.org
+ export PERSISTENT_IDENTIFIER=doi:10.5072/FK2/7U7YBV
+ export JSON='{"fileIds":[300,301]}'
+
+ curl -H "X-Dataverse-key: $API_TOKEN" -H "Content-Type:application/json" "$SERVER_URL/api/datasets/:persistentId/files/actions/:unset-retention?persistentId=$PERSISTENT_IDENTIFIER" -d "$JSON"
.. _Archival Status API:
diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst
index 065277c06ee..dece4a2fcfe 100644
--- a/doc/sphinx-guides/source/installation/config.rst
+++ b/doc/sphinx-guides/source/installation/config.rst
@@ -4549,6 +4549,18 @@ can enter for an embargo end date. This limit will be enforced in the popup dial
``curl -X PUT -d 24 http://localhost:8080/api/admin/settings/:MaxEmbargoDurationInMonths``
+.. _:MinRetentionDurationInMonths:
+
+:MinRetentionDurationInMonths
++++++++++++++++++++++++++++++
+
+This setting controls whether retention periods are allowed in a Dataverse instance and can limit the minimum duration users are allowed to specify. A value of 0 months or non-existent
+setting indicates retention periods are not supported. A value of -1 allows retention periods of any length. Any other value indicates the minimum number of months (from the current date) a user
+can enter for a retention period end date. This limit will be enforced in the popup dialog in which users enter the retention period end date. For example, to set a ten year minimum:
+
+``curl -X PUT -d 120 http://localhost:8080/api/admin/settings/:MinRetentionDurationInMonths``
+
+
:DataverseMetadataValidatorScript
+++++++++++++++++++++++++++++++++
diff --git a/doc/sphinx-guides/source/user/dataset-management.rst b/doc/sphinx-guides/source/user/dataset-management.rst
index 9538be4a1ec..d803aae6d19 100755
--- a/doc/sphinx-guides/source/user/dataset-management.rst
+++ b/doc/sphinx-guides/source/user/dataset-management.rst
@@ -735,6 +735,14 @@ Once a dataset with embargoed files has been published, no further action is nee
As the primary use case of embargoes is to make the existence of data known now, with a promise (to a journal, project team, etc.) that the data itself will become available at a given future date, users cannot change an embargo once a dataset version is published. Dataverse instance administrators do have the ability to correct mistakes and make changes if/when circumstances warrant.
+Retention Periods
+=================
+
+Support for file-level retention periods can also be configured in a Dataverse instance. Retention periods make file content inaccessible after the retention period end date. This means that file previews and the ability to download files will be blocked. The effect is similar to when a file is restricted except that the retention periods will end at the specified date without further action and after the retention periods expires, requests for file access cannot be made.
+
+Retention periods are intended to support use cases where files must be made unavailable - and in most cases destroyed, e.g. to meet legal requirements - after a certain period or date.
+Actual destruction is not automatically handled, but would have to be done on the storage if needed.
+
Dataset Versions
================
diff --git a/src/main/java/edu/harvard/iq/dataverse/DataFile.java b/src/main/java/edu/harvard/iq/dataverse/DataFile.java
index 53cdff31cc2..29a4a14c021 100644
--- a/src/main/java/edu/harvard/iq/dataverse/DataFile.java
+++ b/src/main/java/edu/harvard/iq/dataverse/DataFile.java
@@ -242,6 +242,18 @@ public void setEmbargo(Embargo embargo) {
this.embargo = embargo;
}
+ @ManyToOne
+ @JoinColumn(name="retention_id")
+ private Retention retention;
+
+ public Retention getRetention() {
+ return retention;
+ }
+
+ public void setRetention(Retention retention) {
+ this.retention = retention;
+ }
+
public DataFile() {
this.fileMetadatas = new ArrayList<>();
initFileReplaceAttributes();
diff --git a/src/main/java/edu/harvard/iq/dataverse/DataFileServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DataFileServiceBean.java
index 8ceb529a5d4..134dfd6cc3d 100644
--- a/src/main/java/edu/harvard/iq/dataverse/DataFileServiceBean.java
+++ b/src/main/java/edu/harvard/iq/dataverse/DataFileServiceBean.java
@@ -1365,7 +1365,10 @@ public Embargo findEmbargo(Long id) {
DataFile d = find(id);
return d.getEmbargo();
}
-
+
+ public boolean isRetentionExpired(FileMetadata fm) {
+ return FileUtil.isRetentionExpired(fm);
+ }
/**
* Checks if the supplied DvObjectContainer (Dataset or Collection; although
* only collection-level storage quotas are officially supported as of now)
diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java b/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java
index 3c0949dd9f4..6bf8547712e 100644
--- a/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java
+++ b/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java
@@ -77,6 +77,7 @@
import java.lang.reflect.Method;
import java.sql.Timestamp;
import java.text.SimpleDateFormat;
+import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
@@ -269,6 +270,8 @@ public enum DisplayMode {
@Inject
EmbargoServiceBean embargoService;
@Inject
+ RetentionServiceBean retentionService;
+ @Inject
LicenseServiceBean licenseServiceBean;
@Inject
DataFileCategoryServiceBean dataFileCategoryService;
@@ -2212,6 +2215,11 @@ private String init(boolean initFull) {
}
}
+ LocalDate minRetentiondate = settingsWrapper.getMinRetentionDate();
+ if (minRetentiondate != null){
+ selectionRetention.setDateUnavailable(minRetentiondate.plusDays(1L));
+ }
+
displayLockInfo(dataset);
displayPublishMessage();
@@ -3699,6 +3707,25 @@ public String deleteFiles() throws CommandException{
}
}
+ //Remove retentions that are no longer referenced
+ //Identify which ones are involved here
+ List orphanedRetentions = new ArrayList();
+ if (selectedFiles != null && selectedFiles.size() > 0) {
+ for (FileMetadata fmd : workingVersion.getFileMetadatas()) {
+ for (FileMetadata fm : selectedFiles) {
+ if (fm.getDataFile().equals(fmd.getDataFile()) && !fmd.getDataFile().isReleased()) {
+ Retention ret = fmd.getDataFile().getRetention();
+ if (ret != null) {
+ ret.getDataFiles().remove(fmd.getDataFile());
+ if (ret.getDataFiles().isEmpty()) {
+ orphanedRetentions.add(ret);
+ }
+ }
+ }
+ }
+ }
+ }
+
deleteFiles(filesToDelete);
String retVal;
@@ -3708,12 +3735,14 @@ public String deleteFiles() throws CommandException{
} else {
retVal = save();
}
-
-
- //And delete them only after the dataset is updated
+
+ // And delete them only after the dataset is updated
for(Embargo emb: orphanedEmbargoes) {
embargoService.deleteById(emb.getId(), ((AuthenticatedUser)session.getUser()).getUserIdentifier());
}
+ for(Retention ret: orphanedRetentions) {
+ retentionService.delete(ret, ((AuthenticatedUser)session.getUser()).getUserIdentifier());
+ }
return retVal;
}
@@ -5380,7 +5409,7 @@ public boolean isFileAccessRequestMultiSignUpButtonEnabled(){
return false;
}
for (FileMetadata fmd : this.selectedRestrictedFiles){
- if (!this.fileDownloadHelper.canDownloadFile(fmd)&& !FileUtil.isActivelyEmbargoed(fmd)){
+ if (!this.fileDownloadHelper.canDownloadFile(fmd) && !FileUtil.isActivelyEmbargoed(fmd)){
return true;
}
}
@@ -5741,7 +5770,10 @@ public boolean isShowPreviewButton(Long fileId) {
public boolean isShowQueryButton(Long fileId) {
DataFile dataFile = datafileService.find(fileId);
- if(dataFile.isRestricted() || !dataFile.isReleased() || FileUtil.isActivelyEmbargoed(dataFile)){
+ if(dataFile.isRestricted()
+ || !dataFile.isReleased()
+ || FileUtil.isActivelyEmbargoed(dataFile)
+ || FileUtil.isRetentionExpired(dataFile)){
return false;
}
@@ -6336,6 +6368,195 @@ private boolean containsOnlyActivelyEmbargoedFiles(List selectedFi
return true;
}
+ public Retention getSelectionRetention() {
+ return selectionRetention;
+ }
+
+ public void setSelectionRetention(Retention selectionRetention) {
+ this.selectionRetention = selectionRetention;
+ }
+
+
+ private Retention selectionRetention = new Retention();
+
+ public boolean isValidRetentionSelection() {
+ //If fileMetadataForAction is set, someone is using the kebab/single file menu
+ if (fileMetadataForAction != null) {
+ if (!fileMetadataForAction.getDataFile().isReleased()) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+ //Otherwise we check the selected files
+ for (FileMetadata fmd : selectedFiles) {
+ if (!fmd.getDataFile().isReleased()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /*
+ * This method checks to see if the selected file/files have a retention that could be removed. It doesn't return true of a released file has a retention.
+ */
+ public boolean isExistingRetention() {
+ if (fileMetadataForAction != null) {
+ if (!fileMetadataForAction.getDataFile().isReleased()
+ && (fileMetadataForAction.getDataFile().getRetention() != null)) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+ for (FileMetadata fmd : selectedFiles) {
+ if (!fmd.getDataFile().isReleased() && (fmd.getDataFile().getRetention() != null)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public boolean isRetentionExpired(List fmdList) {
+ return FileUtil.isRetentionExpired(fmdList);
+ }
+
+ public boolean isRetentionForWholeSelection() {
+ for (FileMetadata fmd : selectedFiles) {
+ if (fmd.getDataFile().isReleased()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean removeRetention=false;
+
+ public boolean isRemoveRetention() {
+ return removeRetention;
+ }
+
+ public void setRemoveRetention(boolean removeRetention) {
+ boolean existing = this.removeRetention;
+ this.removeRetention = removeRetention;
+ //If we flipped the state, update the selectedRetention. Otherwise (e.g. when save is hit) don't make changes
+ if(existing != this.removeRetention) {
+ logger.fine("State flip");
+ selectionRetention= new Retention();
+ if(removeRetention) {
+ logger.fine("Setting empty retention");
+ selectionRetention= new Retention(null, null);
+ }
+ PrimeFaces.current().resetInputs("datasetForm:retentionInputs");
+ }
+ }
+
+ public String saveRetention() {
+ if (workingVersion.isReleased()) {
+ refreshSelectedFiles(selectedFiles);
+ }
+
+ if(isRemoveRetention() || (selectionRetention.getDateUnavailable()==null && selectionRetention.getReason()==null)) {
+ selectionRetention=null;
+ }
+
+ if(!(selectionRetention==null || (selectionRetention!=null && settingsWrapper.isValidRetentionDate(selectionRetention)))) {
+ logger.fine("Validation error: " + selectionRetention.getFormattedDateUnavailable());
+ FacesContext.getCurrentInstance().validationFailed();
+ return "";
+ }
+ List orphanedRetentions = new ArrayList();
+ List retentionFMs = null;
+ if (fileMetadataForAction != null) {
+ retentionFMs = new ArrayList();
+ retentionFMs.add(fileMetadataForAction);
+ } else if (selectedFiles != null && selectedFiles.size() > 0) {
+ retentionFMs = selectedFiles;
+ }
+
+ if(retentionFMs!=null && !retentionFMs.isEmpty()) {
+ if(selectionRetention!=null) {
+ selectionRetention = retentionService.merge(selectionRetention);
+ }
+ for (FileMetadata fmd : workingVersion.getFileMetadatas()) {
+ for (FileMetadata fm : retentionFMs) {
+ if (fm.getDataFile().equals(fmd.getDataFile()) && (isSuperUser()||!fmd.getDataFile().isReleased())) {
+ Retention ret = fmd.getDataFile().getRetention();
+ if (ret != null) {
+ logger.fine("Before: " + ret.getDataFiles().size());
+ ret.getDataFiles().remove(fmd.getDataFile());
+ if (ret.getDataFiles().isEmpty()) {
+ orphanedRetentions.add(ret);
+ }
+ logger.fine("After: " + ret.getDataFiles().size());
+ }
+ fmd.getDataFile().setRetention(selectionRetention);
+ }
+ }
+ }
+ }
+ if (selectionRetention != null) {
+ retentionService.save(selectionRetention, ((AuthenticatedUser) session.getUser()).getIdentifier());
+ }
+ // success message:
+ String successMessage = BundleUtil.getStringFromBundle("file.assignedRetention.success");
+ logger.fine(successMessage);
+ successMessage = successMessage.replace("{0}", "Selected Files");
+ JsfHelper.addFlashMessage(successMessage);
+ selectionRetention = new Retention();
+
+ save();
+ for(Retention ret: orphanedRetentions) {
+ retentionService.delete(ret, ((AuthenticatedUser)session.getUser()).getUserIdentifier());
+ }
+ return returnToDraftVersion();
+ }
+
+ public void clearRetentionPopup() {
+ logger.fine("clearRetentionPopup called");
+ selectionRetention= new Retention();
+ setRemoveRetention(false);
+ PrimeFaces.current().resetInputs("datasetForm:retentionInputs");
+ }
+
+ public void clearSelectionRetention() {
+ logger.fine("clearSelectionRetention called");
+ selectionRetention= new Retention();
+ PrimeFaces.current().resetInputs("datasetForm:retentionInputs");
+ }
+
+ public boolean isCantDownloadDueToRetention() {
+ if (getSelectedNonDownloadableFiles() != null) {
+ for (FileMetadata fmd : getSelectedNonDownloadableFiles()) {
+ if (FileUtil.isRetentionExpired(fmd)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ public boolean isCantRequestDueToRetention() {
+ if (fileDownloadHelper.getFilesForRequestAccess() != null) {
+ for (DataFile df : fileDownloadHelper.getFilesForRequestAccess()) {
+ if (FileUtil.isRetentionExpired(df)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean containsOnlyRetentionExpiredFiles(List selectedFiles) {
+ for (FileMetadata fmd : selectedFiles) {
+ if (!FileUtil.isRetentionExpired(fmd)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
public String getIngestMessage() {
return BundleUtil.getStringFromBundle("file.ingestFailed.message", Arrays.asList(settingsWrapper.getGuidesBaseUrl(), settingsWrapper.getGuidesVersion()));
}
diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DatasetServiceBean.java
index 9c182164d37..180a9b6f932 100644
--- a/src/main/java/edu/harvard/iq/dataverse/DatasetServiceBean.java
+++ b/src/main/java/edu/harvard/iq/dataverse/DatasetServiceBean.java
@@ -128,6 +128,7 @@ public Dataset findDeep(Object pk) {
.setHint("eclipselink.left-join-fetch", "o.files.fileMetadatas.fileCategories")
//.setHint("eclipselink.left-join-fetch", "o.files.guestbookResponses")
.setHint("eclipselink.left-join-fetch", "o.files.embargo")
+ .setHint("eclipselink.left-join-fetch", "o.files.retention")
.setHint("eclipselink.left-join-fetch", "o.files.fileAccessRequests")
.setHint("eclipselink.left-join-fetch", "o.files.owner")
.setHint("eclipselink.left-join-fetch", "o.files.releaseUser")
diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetVersionFilesServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DatasetVersionFilesServiceBean.java
index 29981eb8f8b..afcfafe976c 100644
--- a/src/main/java/edu/harvard/iq/dataverse/DatasetVersionFilesServiceBean.java
+++ b/src/main/java/edu/harvard/iq/dataverse/DatasetVersionFilesServiceBean.java
@@ -246,6 +246,8 @@ private long getFileMetadataCountByAccessStatus(DatasetVersion datasetVersion, F
private Predicate createSearchCriteriaAccessStatusPredicate(FileAccessStatus accessStatus, CriteriaBuilder criteriaBuilder, Root fileMetadataRoot) {
Path