diff --git a/doc/release-notes/11747-review-dataset-type.md b/doc/release-notes/11747-review-dataset-type.md new file mode 100644 index 00000000000..7a67b84f5d0 --- /dev/null +++ b/doc/release-notes/11747-review-dataset-type.md @@ -0,0 +1,24 @@ +## Highlights + +### Review Datasets + +Dataverse now supports review datasets, a type of dataset that can be used to review resources such as other datasets in the Dataverse installation itself or various resources in external data repositories. APIs and a new "review" metadata block (with an "Item Reviewed" field) are in place but the UI for this feature will only available in a future version of the new React-based [Dataverse Frontend](https://github.com/IQSS/dataverse-frontend). See also the [guides](https://dataverse-guide--11753.org.readthedocs.build/en/11753/api/native-api.html#add-dataset-type), #11747, #12015, #11887, #12115, and #11753. + +## Other Features Added + +- Citation Style Language (CSL) output now includes "type:software" or "type:review" when those dataset types are used. See the [guides](https://dataverse-guide--11753.org.readthedocs.build/en/11753/api/native-api.html#get-citation-in-other-formats) and #11753. + +## Updated APIs + +- The Change Collection Attributes API now supports `allowedDatasetTypes`. See the [guides](https://dataverse-guide--11753.org.readthedocs.build/en/11753/api/native-api.html#change-collection-attributes), #12115, and #11753. + +## Bugs Fixed + +- 500 error when deleting dataset type by name. See #11833 and #11753. +- Dataset Type facet works in JSF but not the SPA. See #11758 and #11753. + +## Backward Incompatible Changes + +### Dataset Types Must Be Allowed, Per-Collection, Before Use + +In previous releases of Dataverse, as soon as additional dataset types were added (such as "software", "workflow", etc.), they could be used by all users when creating datasets (via API only). As of this release, on a per-collection basis, superusers must allow these dataset types to be used. See #12115 and #11753. diff --git a/doc/sphinx-guides/source/admin/dataverses-datasets.rst b/doc/sphinx-guides/source/admin/dataverses-datasets.rst index c916b79aaa8..a8fc8bc9deb 100644 --- a/doc/sphinx-guides/source/admin/dataverses-datasets.rst +++ b/doc/sphinx-guides/source/admin/dataverses-datasets.rst @@ -109,6 +109,78 @@ If the :AllowedCurationLabels setting has a value, one of the available choices Individual datasets can be configured to use specific curationLabelSets as well. See the "Datasets" section below. +.. _review-datasets-setup: + +Configure a Collection for Review Datasets +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:ref:`review-datasets-user` are a specialized type of dataset that can be used to review resources (such as datasets) in the Dataverse installation itself or resources in external data repositories. + +Review datasets require some setup, as described below. + +Load the Review Metadata Block +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +First, download the Review metadata block tsv file from :ref:`experimental-metadata`. + +Then, load the block and update Solr. See the following sections of :doc:`metadatacustomization` for details: + +- :ref:`load-tsv` +- :ref:`update-solr-schema` + +Create a Collection for Reviews and Configure Permissions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Follow the normal steps: + +- :ref:`create-dataverse`. +- :ref:`dataverse-permissions`. + +Mark Fields in the "Review" Metadata Block as Required for the Collection +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Follow the normal steps to edit the collection (:ref:`general-information`), then enable the Review block for that collection and make the following fields required for the collection you created: + +- URL (``itemReviewedUrl``) +- Type (``itemReviewedType``) + +There is a third field that you can optionally make required: + +- Citation (``itemReviewedCitation``) + +Create and Enable Custom "Rubric" Metadata Blocks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The Review metadata block gives you a few basic fields common to all reviews such as the URL of the item being reviewed. + +You probably will want to create your own metadata blocks specific to the resources you are reviewing, your own "rubric". See :doc:`metadatacustomization` for details on creating and enabling custom metadata blocks. + +Instead of creating a new custom metadata block from scratch (if you simply want to evaluate the feature, for example), you can use the metadata blocks at https://github.com/IQSS/dataverse.harvard.edu + +After loading the block, don't forget to update the Solr schema! + +Create a Review Dataset Type +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Review datasets are built on the :ref:`dataset-types` feature. Dataset types can only be created via API so follow the steps under :ref:`api-add-dataset-type`. Copy and paste from below or download :download:`review.json <../../../../scripts/api/data/datasetTypes/review.json>` and pass it to the API. + +.. literalinclude:: ../../../../scripts/api/data/datasetTypes/review.json + :language: json + +We suggest using neither "linkedMetadataBlocks" nor "availableLicenses" in the JSON when creating the dataset type. Again, the JSON above is suggested. + +Allow the Review Dataset Type for the Collection +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Non-dataset types, such as the "review" type, are only available when a collection admin has enabled them, via API. + +Using the API :ref:`collection-attributes-api`, change the ``allowedDatasetTypes`` attribute so that it includes "review". If you only want to allow reviews, you can pass just ``review``. If you want to allow multiple dataset types, you can pass a comma-separated list, such as ``review,dataset``. + +Invite Users to Create Review Datasets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +At this point, users should be able to create review datasets via API, you gave them permission on the collection. You can point them to :ref:`creating-a-review-dataset` for details. + Datasets -------- diff --git a/doc/sphinx-guides/source/admin/metadatacustomization.rst b/doc/sphinx-guides/source/admin/metadatacustomization.rst index 3cdee6d779a..e7875c247b2 100644 --- a/doc/sphinx-guides/source/admin/metadatacustomization.rst +++ b/doc/sphinx-guides/source/admin/metadatacustomization.rst @@ -444,6 +444,8 @@ Please note that metadata fields share a common namespace so they must be unique We'll use this command again below to update the Solr schema to accomodate metadata fields we've added. +.. _load-tsv: + Loading TSV files into a Dataverse Installation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/sphinx-guides/source/api/changelog.rst b/doc/sphinx-guides/source/api/changelog.rst index 4c7a5914b1e..1b49a0982f4 100644 --- a/doc/sphinx-guides/source/api/changelog.rst +++ b/doc/sphinx-guides/source/api/changelog.rst @@ -10,6 +10,7 @@ This API changelog is experimental and we would love feedback on its usefulness. v6.9 ---- +- When creating datasets that contain a datasetType, that datasetType must be allowed at the collection level. This can be accomplished by passing ``allowedDatasetTypes`` to the :ref:`collection-attributes-api` API. - The POST /api/admin/makeDataCount/{id}/updateCitationsForDataset processing is now asynchronous and the response no longer includes the number of citations. The response can be OK if the request is queued or 503 if the queue is full (default queue size is 1000). - The way to set per-format size limits for tabular ingest has changed. JSON input is now used. See :ref:`:TabularIngestSizeLimit`. - In the past, the settings API would accept any key and value. This is no longer the case because validation has been added. See :ref:`settings_put_single`, for example. diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index eab71f8623b..a1874aa1501 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -1015,8 +1015,8 @@ You should expect an HTTP 200 ("OK") response and JSON indicating the database I .. _api-create-dataset-with-type: -Create a Dataset with a Dataset Type (Software, etc.) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Create a Dataset with a Dataset Type (Software, Workflow, Review, etc.) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ By default, datasets are given the type "dataset" but if your installation had added additional types (see :ref:`api-add-dataset-type`), you can specify the type. @@ -1070,8 +1070,8 @@ Before calling the API, make sure the data files referenced by the ``POST``\ ed .. _import-dataset-with-type: -Import a Dataset with a Dataset Type (Software, etc.) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Import a Dataset with a Dataset Type (Software, Workflow, Review, etc.) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ By default, datasets are given the type "dataset" but if your installation had added additional types (see :ref:`api-add-dataset-type`), you can specify the type. @@ -1185,6 +1185,7 @@ The following attributes are supported: * ``affiliation`` Affiliation * ``filePIDsEnabled`` ("true" or "false") Restricted to use by superusers and only when the :ref:`:AllowEnablingFilePIDsPerCollection <:AllowEnablingFilePIDsPerCollection>` setting is true. Enables or disables registration of file-level PIDs in datasets within the collection (overriding the instance-wide setting). * ``requireFilesToPublishDataset`` ("true" or "false") Restricted to use by superusers. Defines if Dataset needs files in order to be published. If not set the determination will be made through inheritance by checking the owners of this collection. Publishing by a superusers will not be blocked. +* ``allowedDatasetTypes`` Restricted to use by superusers. By default "dataset" is implied. Pass a comma-separated list of dataset types (e.g. "dataset,software"). You cannot unset this attribute so if you want to delete a dataset type, set ``allowedDatasetTypes`` to a dataset type you won't be deleting. See also :ref:`dataset-types`. See also :ref:`update-dataverse-api`. @@ -3958,11 +3959,13 @@ Usage example: curl -H "Accept:application/json" "$SERVER_URL/api/datasets/:persistentId/versions/$VERSION/citation?persistentId=$PERSISTENT_IDENTIFIER&includeDeaccessioned=true" +.. _get-citation-in-other-formats: + Get Citation In Other Formats ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Dataverse can also generate dataset citations in "EndNote", "RIS", "BibTeX", and "CSLJson" formats. -Unlike the call above, which wraps the result in JSON, this API call sends the raw format with the appropriate content-type (EndNote is XML, RIS and BibTeX are plain text, and CSLJson is JSON). ("Internal" is also a valid value, returning the same content as the above call as HTML). +Dataverse can also generate dataset citations in "EndNote", "RIS", "BibTeX", and "CSL" formats. +Unlike the call above, which wraps the result in JSON, this API call sends the raw format with the appropriate content-type (EndNote is XML, RIS and BibTeX are plain text, and CSL is JSON). ("Internal" is also a valid value, returning the same content as the above call as HTML). This API call adds a format parameter in the API call which can be any of the values listed above. Usage example: @@ -3986,6 +3989,7 @@ Usage example: curl "$SERVER_URL/api/datasets/:persistentId/versions/$VERSION/citation/$FORMAT?persistentId=$PERSISTENT_IDENTIFIER&includeDeaccessioned=true" +The type under CSL can vary based on the dataset type, with "dataset", "software", and "review" as supported values. See also :ref:`dataset-types`. Get Citation by Preview URL Token ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -4237,7 +4241,11 @@ The fully expanded example above (without environment variables) looks like this Add Dataset Type ^^^^^^^^^^^^^^^^ -Note: Before you add any types of your own, there should be a single type called "dataset". If you add "software" or "workflow", these types will be sent to DataCite (if you use DataCite). Otherwise, the only functionality you gain currently from adding types is an entry in the "Dataset Type" facet but be advised that if you add a type other than "software" or "workflow", you will need to add your new type to your Bundle.properties file for it to appear in Title Case rather than lower case in the "Dataset Type" facet. +Note: Before you add any types of your own, there should be a single type called "dataset". + +Adding certain dataset types will result in a value other than "Dataset" being sent to DataCite (if you use DataCite), see :ref:`dataset-types-datacite` for details. + +Be advised that if you add a type other than "software", "workflow", or "review", you will need to add your new type to your Bundle.properties file for it to appear in Title Case rather than lower case in the "Dataset Type" facet. With all that said, we'll add a "software" type in the example below. This API endpoint is superuser only. The "name" of a type cannot be only digits. Note that this endpoint also allows you to add metadata blocks and available licenses for your new dataset type by adding "linkedMetadataBlocks" and/or "availableLicenses" arrays to your JSON. @@ -4260,7 +4268,7 @@ The fully expanded example above (without environment variables) looks like this Delete Dataset Type ^^^^^^^^^^^^^^^^^^^ -Superuser only. +Superuser only. Note that if a collection has the type listed as an allowed dataset type, you will be unable to delete the dataset type until you first use the :ref:`collection-attributes-api` to change ``allowedDatasetTypes`` to a dataset type (or dataset types) that you are not trying to delete. .. code-block:: bash diff --git a/doc/sphinx-guides/source/user/appendix.rst b/doc/sphinx-guides/source/user/appendix.rst index d1c46a93fdf..e2c78e1e99c 100755 --- a/doc/sphinx-guides/source/user/appendix.rst +++ b/doc/sphinx-guides/source/user/appendix.rst @@ -17,6 +17,8 @@ The Dataverse Project is committed to using standard-compliant metadata to ensur metadata can be mapped easily to standard metadata schemas and be exported into JSON format (XML for tabular file metadata) for preservation and interoperability. +.. _supported-metadata: + Supported Metadata ~~~~~~~~~~~~~~~~~~ @@ -34,6 +36,8 @@ Detailed below are what metadata schemas we support for Citation and Domain Spec - Journal Metadata (`see .tsv `__): based on the `Journal Archiving and Interchange Tag Set, version 1.2 `__. - 3D Objects Metadata (`see .tsv `__). +.. _experimental-metadata: + Experimental Metadata ~~~~~~~~~~~~~~~~~~~~~ @@ -43,6 +47,7 @@ Unlike supported metadata, experimental metadata is not enabled by default in a - Computational Workflow Metadata (`see .tsv `__): adapted from `Bioschemas Computational Workflow Profile, version 1.0 `__ and `Codemeta `__. - Archival Metadata (`see .tsv `__): Enables repositories to register metadata relating to the potential archiving of the dataset at a depositor archive, whether that be your own institutional archive or an external archive, i.e. a historical archive. - Local Contexts Metadata (`see .tsv `__): Supports integration with the `Local Contexts `__ platform, enabling the use of Traditional Knowledge and Biocultural Labels, and Notices. For more information on setup and configuration, see :doc:`../installation/localcontexts`. +- Review Metadata (`see .tsv `__): For :ref:`review-datasets-user`. Please note: these custom metadata schemas are not included in the Solr schema for indexing by default, you will need to add them as necessary for your custom metadata blocks. See "Update the Solr Schema" in :doc:`../admin/metadatacustomization`. diff --git a/doc/sphinx-guides/source/user/dataset-management.rst b/doc/sphinx-guides/source/user/dataset-management.rst index 22e72a6a210..7d54aa1df54 100755 --- a/doc/sphinx-guides/source/user/dataset-management.rst +++ b/doc/sphinx-guides/source/user/dataset-management.rst @@ -847,21 +847,113 @@ Dataset Types .. note:: Development of the dataset types feature is ongoing. Please see https://github.com/IQSS/dataverse-pm/issues/307 for details. -Out of the box, all datasets have a dataset type of "dataset". Superusers can add additional types such as "software" or "workflow" using the :ref:`api-add-dataset-type` API endpoint. +The vision for dataset types is to have variations on datasets. The best documented use case is :ref:`review-datasets-user`, as explained below, but other types of datasets are possible such as software datasets (see :ref:`api-add-dataset-type` for an example) or workflow datasets. -Once more than one type appears in search results, a facet called "Dataset Type" will appear allowing you to filter down to a certain type. +Out of the box, all datasets have a dataset type of "dataset", which is the traditional dataset in Dataverse. Superusers can add additional types using the :ref:`api-add-dataset-type` API endpoint. These additional dataset types cannot be used until a superuser has allowed them on a per-collection basis using the :ref:`collection-attributes-api` API endpoint (by passing ``allowedDatasetTypes``). -If your installation is configured to use DataCite as a persistent ID (PID) provider, the appropriate type ("Dataset", "Software", "Workflow") will be sent to DataCite when the dataset is published for those three types. +Dataset types can be listed, added, or deleted via API. See :ref:`api-dataset-types` in the API Guide for more. -Currently, specifying a type for a dataset can only be done via API and only when the dataset is created. The type can't currently be changed afterward. For details, see the following sections of the API guide: +Currently, specifying a type for a dataset can only be done via API and only when the dataset is created. (The type can't be changed afterward.) For details, see the following sections of the API guide: - :ref:`api-create-dataset-with-type` (Native API) - :ref:`api-semantic-create-dataset-with-type` (Semantic API) - :ref:`import-dataset-with-type` -Dataset types can be listed, added, or deleted via API. See :ref:`api-dataset-types` in the API Guide for more. +Once more than one type appears in dataset search results, a facet called "Dataset Type" will appear allowing you to filter down to a certain type. + +Dataset types can be linked with metadata blocks to make fields from those blocks available when datasets of that type are created or edited (via API). See :ref:`api-link-dataset-type` and :ref:`list-metadata-blocks-for-a-collection` for details. + +Dataset types can change the "type" in Citation Style Language (CSL) output. See :ref:`get-citation-in-other-formats` for details. + +If your installation is configured to use DataCite as a persistent ID (PID) provider, the dataset type may be sent to DataCite as ``resourceTypeGeneral``. See the table under :ref:`dataset-types-datacite` for details. + +.. _review-datasets-user: + +Review Datasets +--------------- + +.. _review-datasets-overview: + +Review Dataset Overview +~~~~~~~~~~~~~~~~~~~~~~~ + +Review datasets are a specialized type of dataset that can be used to review resources (such as datasets) in the Dataverse installation itself or resources in external data repositories. + +This feature is only available via API and only if it has been configured by a superuser for your collection. See :ref:`review-datasets-setup` for details. + +In the recommended setup, a collection is created that is managed by a research community, typically approved at the installation level. + +In a typical use case, the reviews will be generated by these research communities based on the aggregation of scores for a particular domain by community-identified experts. These scores are stored in a custom metadata block, a rubric. An additional metadata block is required to hold information about the review itself, such a pointer to the resource being reviewed. + +We recommend implementing a policy where there is only one review of a given resource per collection. + +Almost all functionality is the same between regular datasets and review datasets. Review datasets build upon existing dataset functionality such as custom metadata blocks, versioning, publishing workflows, permissions, and file handling. + +Review datasets build on the :ref:`dataset-types` feature, allowing users to choose a dataset type of "review", which leads to a couple differences from regular datasets. + +First, when multiple dataset types exist, a "Dataset Type" search facet appears that allows users to narrow results to the various kinds of dataset types that have been added, such as dataset, review, software, workflow, etc. (Under the "Collections, Datasets, Files" area, review datasets are considered datasets.) + +Second, when review datasets are published, different ``resourceType`` metadata is sent to DataCite. Review datasets send "Other" for the field resourceTypeGeneral ("Work Type" in the UI at https://commons.datacite.org). See the table under :ref:`dataset-types-datacite` for details. + +The following table summaries how regular datasets compare to review datasets. + +.. list-table:: Differences between regular and review datasets + :header-rows: 1 + :stub-columns: 1 + :align: left + + * - + - Regular Dataset + - Review Dataset + * - Collections/Datasets/Files search facet + - dataset + - dataset + * - Dataset Type search facet + - dataset + - review + * - DataCite + - See table under :ref:`api-add-dataset-type` + - See table under :ref:`api-add-dataset-type` + +.. _creating-a-review-dataset: + +Creating a Review Dataset +~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can only create a review dataset if setup has already been done by a superuser. See :ref:`review-datasets-setup` for details. + +Review Datasets can only be created via API. You have the following options: + +- :ref:`api-create-dataset-with-type` (Native API) +- :ref:`api-semantic-create-dataset-with-type` (Semantic API) +- :ref:`import-dataset-with-type` + +When creating a review dataset you will likely need to fill in required fields like ``itemReviewedUrl`` as well as fields from one or more "rubric" metadata blocks, as described above under :ref:`review-datasets-overview`. + +.. _dataset-types-datacite: + +Dataset Types and DataCite +-------------------------- + +Adding certain dataset types will result in a value other than "Dataset" being sent to DataCite (if you use DataCite) as shown in the table below. + +.. list-table:: Values sent to DataCite for resourceTypeGeneral by Dataset Type + :header-rows: 1 + :stub-columns: 1 + :align: left + + * - Dataset Type + - Value sent to DataCite + * - dataset + - + * - software + - + * - workflow + - + * - review + - Review -Dataset types can be linked with metadata blocks to make fields from those blocks available when datasets of that type are created or edited. See :ref:`api-link-dataset-type` and :ref:`list-metadata-blocks-for-a-collection` for details. +Note that the value for resourceType (which is either empty or "Review", as shown above) can be overridden by values in the "Data Type" (``kindOfData``) metadata field. .. |image1| image:: ./img/DatasetDiagram.png :class: img-responsive diff --git a/scripts/api/data/datasetTypes/review.json b/scripts/api/data/datasetTypes/review.json new file mode 100644 index 00000000000..44e421ef31f --- /dev/null +++ b/scripts/api/data/datasetTypes/review.json @@ -0,0 +1,5 @@ +{ + "name": "review", + "displayName": "Review", + "description": "A review of a dataset compiled by community experts." +} diff --git a/scripts/api/data/metadatablocks/review.tsv b/scripts/api/data/metadatablocks/review.tsv new file mode 100644 index 00000000000..54e0760b325 --- /dev/null +++ b/scripts/api/data/metadatablocks/review.tsv @@ -0,0 +1,40 @@ +#metadataBlock name dataverseAlias displayName + review Review Metadata +#datasetField name title description watermark fieldType displayOrder displayFormat advancedSearchField allowControlledVocabulary allowmultiples facetable displayoncreate required parent metadatablock_id termURI + itemReviewed Item Reviewed The item being reviewed none 1 FALSE FALSE FALSE FALSE FALSE FALSE review + itemReviewedUrl URL The URL of the item being reviewed url 2 FALSE FALSE FALSE FALSE FALSE FALSE itemReviewed review + itemReviewedType Type The type of the item being reviewed text 3 FALSE TRUE FALSE FALSE FALSE FALSE itemReviewed review + itemReviewedCitation Citation The full bibliographic citation of the item being reviewed textbox 4 FALSE FALSE FALSE FALSE FALSE FALSE itemReviewed review +#controlledVocabulary DatasetField Value identifier displayOrder + itemReviewedType Audiovisual 0 + itemReviewedType Award 1 + itemReviewedType Book 2 + itemReviewedType Book Chapter 3 + itemReviewedType Collection 4 + itemReviewedType Computational Notebook 5 + itemReviewedType Conference Paper 6 + itemReviewedType Conference Proceeding 7 + itemReviewedType DataPaper 8 + itemReviewedType Dataset 9 + itemReviewedType Dissertation 10 + itemReviewedType Event 11 + itemReviewedType Image 12 + itemReviewedType Interactive Resource 13 + itemReviewedType Instrument 14 + itemReviewedType Journal 15 + itemReviewedType Journal Article 16 + itemReviewedType Model 17 + itemReviewedType Output Management Plan 18 + itemReviewedType Peer Review 19 + itemReviewedType Physical Object 20 + itemReviewedType Preprint 21 + itemReviewedType Project 22 + itemReviewedType Report 23 + itemReviewedType Service 24 + itemReviewedType Software 25 + itemReviewedType Sound 26 + itemReviewedType Standard 27 + itemReviewedType Study Registration 28 + itemReviewedType Text 29 + itemReviewedType Workflow 30 + itemReviewedType Other 31 \ No newline at end of file diff --git a/src/main/java/edu/harvard/iq/dataverse/DataCitation.java b/src/main/java/edu/harvard/iq/dataverse/DataCitation.java index 30d9928f59a..57734911470 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataCitation.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataCitation.java @@ -740,8 +740,11 @@ public Map getDataCiteMetadata() { public JsonObject getCSLJsonFormat() { CSLItemDataBuilder itemBuilder = new CSLItemDataBuilder(); - if (type.equals(DatasetType.DATASET_TYPE_SOFTWARE)) { + // TODO consider making this a switch + if (type.getName().equals(DatasetType.DATASET_TYPE_SOFTWARE)) { itemBuilder.type(CSLType.SOFTWARE); + } else if (type.getName().equals(DatasetType.DATASET_TYPE_REVIEW)) { + itemBuilder.type(CSLType.REVIEW); } else { itemBuilder.type(CSLType.DATASET); } diff --git a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java index 98c3e965dad..ea27bbd7f8e 100644 --- a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java +++ b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java @@ -3,6 +3,7 @@ import edu.harvard.iq.dataverse.dataverse.featured.DataverseFeaturedItem; import edu.harvard.iq.dataverse.harvest.client.HarvestingClient; import edu.harvard.iq.dataverse.authorization.DataverseRole; +import edu.harvard.iq.dataverse.dataset.DatasetType; import edu.harvard.iq.dataverse.search.savedsearch.SavedSearch; import edu.harvard.iq.dataverse.storageuse.StorageUse; import edu.harvard.iq.dataverse.util.BundleUtil; @@ -166,6 +167,13 @@ public String getIndexableCategoryName() { } private String affiliation; + + /** + * If null, only the default dataset type (dataset) is allowed. + * See AbstractCreateDatasetCommand. + */ + @ManyToMany(cascade = {CascadeType.MERGE}) + private List allowedDatasetTypes = new ArrayList<>(); ///private String storageDriver=null; @@ -779,6 +787,14 @@ public void setAffiliation(String affiliation) { this.affiliation = affiliation; } + public List getAllowedDatasetTypes() { + return allowedDatasetTypes; + } + + public void setAllowedDatasetTypes(List allowedDatasetTypes) { + this.allowedDatasetTypes = allowedDatasetTypes; + } + public boolean isMetadataBlockRoot() { return metadataBlockRoot; } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java index 1b3016ec2f4..d1bd96e97ae 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -35,6 +35,7 @@ import edu.harvard.iq.dataverse.externaltools.ExternalToolHandler; import edu.harvard.iq.dataverse.globus.GlobusServiceBean; import edu.harvard.iq.dataverse.globus.GlobusUtil; +import edu.harvard.iq.dataverse.i18n.I18nUtil; import edu.harvard.iq.dataverse.ingest.IngestServiceBean; import edu.harvard.iq.dataverse.ingest.IngestUtil; import edu.harvard.iq.dataverse.makedatacount.*; @@ -100,13 +101,12 @@ import java.util.stream.Collectors; import static edu.harvard.iq.dataverse.api.ApiConstants.*; -import edu.harvard.iq.dataverse.dataset.DatasetType; -import edu.harvard.iq.dataverse.dataset.DatasetTypeServiceBean; import edu.harvard.iq.dataverse.license.License; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.*; import static edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder.jsonObjectBuilder; +import static jakarta.ws.rs.core.HttpHeaders.ACCEPT_LANGUAGE; import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; import static jakarta.ws.rs.core.Response.Status.NOT_FOUND; import static jakarta.ws.rs.core.Response.Status.FORBIDDEN; @@ -5719,17 +5719,19 @@ public Response resetPidGenerator(@Context ContainerRequestContext crc, @PathPar @GET @Path("datasetTypes") - public Response getDatasetTypes() { + public Response getDatasetTypes(@HeaderParam(ACCEPT_LANGUAGE) String acceptLanguage) { + Locale locale = I18nUtil.parseAcceptLanguageHeader(acceptLanguage); JsonArrayBuilder jab = Json.createArrayBuilder(); for (DatasetType datasetType : datasetTypeSvc.listAll()) { - jab.add(datasetType.toJson()); + jab.add(datasetType.toJson(locale)); } return ok(jab); } @GET @Path("datasetTypes/{idOrName}") - public Response getDatasetTypes(@PathParam("idOrName") String idOrName) { + public Response getDatasetTypes(@PathParam("idOrName") String idOrName, @HeaderParam(ACCEPT_LANGUAGE) String acceptLanguage) { + Locale locale = I18nUtil.parseAcceptLanguageHeader(acceptLanguage); DatasetType datasetType = null; if (StringUtils.isNumeric(idOrName)) { try { @@ -5742,7 +5744,7 @@ public Response getDatasetTypes(@PathParam("idOrName") String idOrName) { datasetType = datasetTypeSvc.getByName(idOrName); } if (datasetType != null) { - return ok(datasetType.toJson()); + return ok(datasetType.toJson(locale)); } else { return error(NOT_FOUND, "Could not find a dataset type with name " + idOrName); } @@ -5767,6 +5769,8 @@ public Response addDatasetType(@Context ContainerRequestContext crc, String json } String nameIn = null; + String displayNameIn = null; + String descriptionIn = null; JsonArrayBuilder datasetTypesAfter = Json.createArrayBuilder(); List metadataBlocksToSave = new ArrayList<>(); @@ -5775,6 +5779,8 @@ public Response addDatasetType(@Context ContainerRequestContext crc, String json try { JsonObject datasetTypeObj = JsonUtil.getJsonObject(jsonIn); nameIn = datasetTypeObj.getString("name"); + displayNameIn = datasetTypeObj.getString("displayName", null); + descriptionIn = datasetTypeObj.getString("description", null); JsonArray arr = datasetTypeObj.getJsonArray("linkedMetadataBlocks"); if (arr != null && !arr.isEmpty()) { @@ -5812,6 +5818,9 @@ public Response addDatasetType(@Context ContainerRequestContext crc, String json if (nameIn == null) { return error(BAD_REQUEST, "A name for the dataset type is required"); } + if (displayNameIn == null) { + return error(BAD_REQUEST, "A displayName for the dataset type is required"); + } if (StringUtils.isNumeric(nameIn)) { // getDatasetTypes supports id or name so we don't want a names that looks like an id return error(BAD_REQUEST, "The name of the type cannot be only digits."); @@ -5820,12 +5829,17 @@ public Response addDatasetType(@Context ContainerRequestContext crc, String json try { DatasetType datasetType = new DatasetType(); datasetType.setName(nameIn); + datasetType.setDisplayName(displayNameIn); + datasetType.setDescription(descriptionIn); datasetType.setMetadataBlocks(metadataBlocksToSave); datasetType.setLicenses(licensesToSave); DatasetType saved = datasetTypeSvc.save(datasetType); Long typeId = saved.getId(); String name = saved.getName(); - return ok(saved.toJson()); + // Locale is null because when creating the dataset type we are relying entirely + // on the database. The new dataset type has not yet been localized in a + // properties file. + return ok(saved.toJson(null)); } catch (WrappedResponse ex) { return error(BAD_REQUEST, ex.getMessage()); } @@ -5853,7 +5867,7 @@ public Response deleteDatasetType(@Context ContainerRequestContext crc, @PathPar try { idToDelete = Long.parseLong(doomed); } catch (NumberFormatException e) { - throw new IllegalArgumentException("ID must be a number"); + return error(BAD_REQUEST,"ID must be a number"); } DatasetType datasetTypeToDelete = datasetTypeSvc.getById(idToDelete); diff --git a/src/main/java/edu/harvard/iq/dataverse/dataset/DatasetType.java b/src/main/java/edu/harvard/iq/dataverse/dataset/DatasetType.java index c55324f66e3..a3e1f18ba46 100644 --- a/src/main/java/edu/harvard/iq/dataverse/dataset/DatasetType.java +++ b/src/main/java/edu/harvard/iq/dataverse/dataset/DatasetType.java @@ -2,6 +2,8 @@ import edu.harvard.iq.dataverse.MetadataBlock; import edu.harvard.iq.dataverse.license.License; +import edu.harvard.iq.dataverse.util.BundleUtil; +import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; import jakarta.json.Json; import jakarta.json.JsonArrayBuilder; import jakarta.json.JsonObjectBuilder; @@ -19,6 +21,9 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.MissingResourceException; +import java.util.logging.Logger; @NamedQueries({ @NamedQuery(name = "DatasetType.findAll", @@ -36,19 +41,37 @@ public class DatasetType implements Serializable { + private static final Logger logger = Logger.getLogger(DatasetType.class.getCanonicalName()); + public static final String DATASET_TYPE_DATASET = "dataset"; public static final String DATASET_TYPE_SOFTWARE = "software"; public static final String DATASET_TYPE_WORKFLOW = "workflow"; + public static final String DATASET_TYPE_REVIEW = "review"; public static final String DEFAULT_DATASET_TYPE = DATASET_TYPE_DATASET; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * Machine readable name to use via API. + */ // Any constraints? @Pattern regexp? @Column(nullable = false) private String name; + /** + * Human readable name to show in the UI. + */ + @Column(nullable = false, columnDefinition = "VARCHAR(255) DEFAULT ''") + private String displayName; + + /** + * Human readable description to show in the UI. + */ + @Column(nullable = true, columnDefinition = "VARCHAR(255) DEFAULT ''") + private String description; + /** * The metadata blocks this dataset type is linked to. */ @@ -80,6 +103,30 @@ public void setName(String name) { this.name = name; } + /** + * In most cases, you should call the getDisplayName(locale) version. This is + * here in case you really want the value from the database. + */ + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + /** + * In most cases, you should call the getDescription(locale) version. This is + * here in case you really want the value from the database. + */ + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + public List getMetadataBlocks() { return metadataBlocks; } @@ -96,7 +143,7 @@ public void setLicenses(List licenses) { this.licenses = licenses; } - public JsonObjectBuilder toJson() { + public JsonObjectBuilder toJson(Locale locale) { JsonArrayBuilder linkedMetadataBlocks = Json.createArrayBuilder(); for (MetadataBlock metadataBlock : this.getMetadataBlocks()) { linkedMetadataBlocks.add(metadataBlock.getName()); @@ -105,11 +152,65 @@ public JsonObjectBuilder toJson() { for (License license : this.getLicenses()) { availableLicenses.add(license.getName()); } - return Json.createObjectBuilder() + return NullSafeJsonBuilder.jsonObjectBuilder() .add("id", getId()) .add("name", getName()) + .add("displayName", getDisplayName(locale)) + .add("description", getDescription(locale)) .add("linkedMetadataBlocks", linkedMetadataBlocks) .add("availableLicenses", availableLicenses); } + public String getDisplayName(Locale locale) { + logger.fine("Getting display name for dataset type " + name + " and locale " + locale); + if (locale == null) { + logger.fine("Locale is null, returning default display name: " + displayName); + return displayName; + } + if (locale.getLanguage().isBlank()) { + logger.fine("Locale couldn't be parsed, returning default display name: " + displayName); + return displayName; + } + if (locale.getLanguage().equals(Locale.ENGLISH.getLanguage())) { + // This is here to prevent looking up datasetTypes_en.properties, which doesn't exist. + // The English strings are in datasetTypes.properties (no _en). + logger.fine("Locale is English, returning default display name: " + displayName); + return displayName; + } + String propertiesFile = "datasetTypes_" + locale.toLanguageTag() + ".properties"; + try { + logger.fine("Looking up " + name + ".displayName in " + propertiesFile); + return BundleUtil.getStringFromPropertyFile(name + ".displayName", "datasetTypes", locale); + } catch (MissingResourceException e) { + logger.fine(name + ".displayName missing from " + propertiesFile + " (or file does not exist). Returning English version."); + return displayName; + } + } + + public String getDescription(Locale locale) { + logger.fine("Getting description for dataset type " + name + " and locale " + locale); + if (locale == null) { + logger.fine("Locale is null, returning default description: " + description); + return description; + } + if (locale.getLanguage().isBlank()) { + logger.fine("Locale couldn't be parsed, returning default description: " + description); + return description; + } + if (locale.getLanguage().equals(Locale.ENGLISH.getLanguage())) { + // This is here to prevent looking up datasetTypes_en.properties, which doesn't exist. + // The English strings are in datasetTypes.properties (no _en). + logger.fine("Locale is English, returning default description: " + description); + return description; + } + String propertiesFile = "datasetTypes_" + locale.toLanguageTag() + ".properties"; + try { + logger.fine("Looking up " + name + ".description in " + propertiesFile); + return BundleUtil.getStringFromPropertyFile(name + ".description", "datasetTypes", locale); + } catch (MissingResourceException e) { + logger.fine(name + ".description missing from " + propertiesFile + " (or file does not exist). Returning English version."); + return description; + } + } + } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/AbstractCreateDatasetCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/AbstractCreateDatasetCommand.java index b36a638956f..075df88d672 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/AbstractCreateDatasetCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/AbstractCreateDatasetCommand.java @@ -11,9 +11,14 @@ import edu.harvard.iq.dataverse.engine.command.DataverseRequest; import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.InvalidFieldsCommandException; import edu.harvard.iq.dataverse.pidproviders.PidProvider; import static edu.harvard.iq.dataverse.util.StringUtil.isEmpty; import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.logging.Logger; import org.apache.solr.client.solrj.SolrServerException; @@ -121,11 +126,33 @@ public Dataset execute(CommandContext ctxt) throws CommandException { } DatasetType defaultDatasetType = ctxt.datasetTypes().getByName(DatasetType.DEFAULT_DATASET_TYPE); + // Why is this called existingDatasetType? Would incomingDatasetType make more sense? DatasetType existingDatasetType = theDataset.getDatasetType(); logger.fine("existing dataset type: " + existingDatasetType); if (existingDatasetType != null) { - // A dataset type can be specified via API, for example. - theDataset.setDatasetType(existingDatasetType); + List allowedByCollection = theDataset.getOwner().getAllowedDatasetTypes(); + // Final because we apply some logic first + List allowedDatasetTypesFinal = new ArrayList<>(); + if (allowedByCollection.isEmpty()) { + // If allowedDatasetTypes is unspecified, assume + // only the default type (dataset) is allowed + allowedDatasetTypesFinal.add(defaultDatasetType); + } else { + allowedDatasetTypesFinal.addAll(allowedByCollection); + } + // Set type if allowed. Otherwise, return error. + if (allowedDatasetTypesFinal.contains(existingDatasetType)) { + theDataset.setDatasetType(existingDatasetType); + } else { + List typeNames = allowedDatasetTypesFinal.stream() + .map(DatasetType::getName) + .toList(); + Map fieldErrors = new HashMap<>(); + fieldErrors.put("datasetType", "The parent collection does not allow the datasetType " + + existingDatasetType.getName() + ". Allowed types: " + String.join(", ", typeNames)); + throw new InvalidFieldsCommandException("The dataset could not be created due to the datasetType.", + this, fieldErrors); + } } else { theDataset.setDatasetType(defaultDatasetType); } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDataverseAttributeCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDataverseAttributeCommand.java index ab12d8eea26..6fdbfa59f69 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDataverseAttributeCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDataverseAttributeCommand.java @@ -2,16 +2,22 @@ import edu.harvard.iq.dataverse.Dataverse; import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.dataset.DatasetType; import edu.harvard.iq.dataverse.engine.command.AbstractCommand; import edu.harvard.iq.dataverse.engine.command.CommandContext; import edu.harvard.iq.dataverse.engine.command.DataverseRequest; import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; +import edu.harvard.iq.dataverse.engine.command.exception.InvalidFieldsCommandException; import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * Command to update an existing Dataverse attribute. @@ -25,6 +31,7 @@ public class UpdateDataverseAttributeCommand extends AbstractCommand private static final String ATTRIBUTE_AFFILIATION = "affiliation"; private static final String ATTRIBUTE_FILE_PIDS_ENABLED = "filePIDsEnabled"; private static final String ATTRIBUTE_REQUIRE_FILES_TO_PUBLISH_DATASET = "requireFilesToPublishDataset"; + private static final String ATTRIBUTE_ALLOWED_DATASET_TYPES = "allowedDatasetTypes"; private final Dataverse dataverse; private final String attributeName; private final Object attributeValue; @@ -49,6 +56,9 @@ public Dataverse execute(CommandContext ctxt) throws CommandException { case ATTRIBUTE_FILE_PIDS_ENABLED: setBooleanAttribute(ctxt, true); break; + case ATTRIBUTE_ALLOWED_DATASET_TYPES: + setAllowedDatasetTypes(ctxt, attributeValue); + break; default: throw new IllegalCommandException("'" + attributeName + "' is not a supported attribute", this); } @@ -116,4 +126,39 @@ private void setBooleanAttribute(CommandContext ctxt, boolean adminOnly) throws throw new IllegalCommandException("Unsupported boolean attribute: " + attributeName, this); } } + + private void setAllowedDatasetTypes(CommandContext ctxt, Object allowedDatasetTypesIn) throws CommandException { + if (!getRequest().getUser().isSuperuser()) { + throw new PermissionException("You must be a superuser to change this setting", + this, null, dataverse); + } + if (!(allowedDatasetTypesIn instanceof String stringValue)) { + throw new IllegalCommandException("'" + ATTRIBUTE_ALLOWED_DATASET_TYPES + "' requires a string value", + this); + } + + List allowedDatasetTypes = new ArrayList<>(); + List invalidDatasetTypes = new ArrayList<>(); + + String[] allowedDatasetTypeNames = stringValue.split(","); + for (String datasetTypeName : allowedDatasetTypeNames) { + DatasetType datasetType = ctxt.datasetTypes().getByName(datasetTypeName.trim()); + if (datasetType == null) { + invalidDatasetTypes.add(datasetTypeName); + } else { + allowedDatasetTypes.add(datasetType); + } + } + + if (!invalidDatasetTypes.isEmpty()) { + Map fieldErrors = new HashMap<>(); + fieldErrors.put(ATTRIBUTE_ALLOWED_DATASET_TYPES, "The following dataset types do not exist: " + + String.join(", ", invalidDatasetTypes)); + throw new InvalidFieldsCommandException("The collection could not be updated because " + + ATTRIBUTE_ALLOWED_DATASET_TYPES + " is invalid.", this, fieldErrors); + } + + dataverse.setAllowedDatasetTypes(allowedDatasetTypes); + } + } diff --git a/src/main/java/edu/harvard/iq/dataverse/i18n/I18nUtil.java b/src/main/java/edu/harvard/iq/dataverse/i18n/I18nUtil.java new file mode 100644 index 00000000000..d7531f60278 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/i18n/I18nUtil.java @@ -0,0 +1,46 @@ +package edu.harvard.iq.dataverse.i18n; + +import java.util.List; +import java.util.Locale; + +public class I18nUtil { + + /** + * A comment from poikilotherm from + * https://github.com/IQSS/dataverse/pull/11753#discussion_r2787986962 + * + * IMHO any parsing of the locale should be done using JAX-RS mechanisms to + * follow DRY principle. + * + * Solution A) Keep @HeaderParam, but make it a Locale, moving the parsing to a + * ParamConverter. This is still a lot of repeated boilerplate code. + * + * Solution B) Have a @Context HttpHeaders parameter give you access via + * headers.getAcceptableLanguages() or a @Context Request give you access via + * request.getLanguage() to the Locale without manual parsing. Still some + * boilerplate per method + * + * Solution C) Create a CDI @Producer method that is @RequestScoped, receiving + * the Locale as a class field @Inject Locale. This would be greatly enhanced by + * adding an annotation like @ClientLocale to be used as qualifier for both + * field and producer method. This is the least boilerplate code. + */ + + /** + * @param acceptLanguageHeader The Accept-Language header value such as + * "Accept-Language: en-US,en;q=0.5" + * @return The first Locale or null. + */ + public static Locale parseAcceptLanguageHeader(String acceptLanguageHeader) { + if (acceptLanguageHeader == null || acceptLanguageHeader.isEmpty()) { + return null; + } + List list = Locale.LanguageRange.parse(acceptLanguageHeader); + if (list.isEmpty()) { + return null; + } + Locale.LanguageRange languageRange = list.get(0); + return Locale.forLanguageTag(languageRange.getRange()); + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/pidproviders/doi/XmlMetadataTemplate.java b/src/main/java/edu/harvard/iq/dataverse/pidproviders/doi/XmlMetadataTemplate.java index 9ebb346baf8..461b583ffe9 100644 --- a/src/main/java/edu/harvard/iq/dataverse/pidproviders/doi/XmlMetadataTemplate.java +++ b/src/main/java/edu/harvard/iq/dataverse/pidproviders/doi/XmlMetadataTemplate.java @@ -836,12 +836,15 @@ private void writeResourceType(XMLStreamWriter xmlw, DvObject dvObject) throws X List kindOfDataValues = new ArrayList(); Map attributes = new HashMap(); String resourceType = "Dataset"; + String datasetTypeName = null; if (dvObject instanceof Dataset dataset) { - String datasetTypeName = dataset.getDatasetType().getName(); + datasetTypeName = dataset.getDatasetType().getName(); resourceType = switch (datasetTypeName) { case DatasetType.DATASET_TYPE_DATASET -> "Dataset"; case DatasetType.DATASET_TYPE_SOFTWARE -> "Software"; case DatasetType.DATASET_TYPE_WORKFLOW -> "Workflow"; + // "Other" for now but we might ask DataCite to support "Review" + case DatasetType.DATASET_TYPE_REVIEW -> "Other"; default -> "Dataset"; }; } @@ -864,6 +867,8 @@ private void writeResourceType(XMLStreamWriter xmlw, DvObject dvObject) throws X if (!kindOfDataValues.isEmpty()) { XmlWriterUtil.writeFullElementWithAttributes(xmlw, "resourceType", attributes, String.join(";", kindOfDataValues)); + } else if (DatasetType.DATASET_TYPE_REVIEW.equals(datasetTypeName)) { + XmlWriterUtil.writeFullElementWithAttributes(xmlw, "resourceType", attributes, "Review"); } else { // Write an attribute only element if there are no kindOfData values. xmlw.writeStartElement("resourceType"); diff --git a/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchServiceBean.java index 530d3f9ef7e..b265ad967d4 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchServiceBean.java @@ -759,12 +759,33 @@ public SolrQueryResponse search( } catch (Exception e) { localefriendlyName = facetFieldCount.getName(); } + } else if (facetField.getName().equals(SearchFields.DATASET_TYPE)) { + /** + * For dataset types we use the machine readable name (e.g. "dataset" or + * "software") rather than the display name (e.g. "Dataset" or "Software") + * because otherwise the facet doesn't work in the SPA when you click it. The + * SPA operates on the "labels" array (see below) and the keys of the objects in + * this array are passed back into the Search API when clicked (e.g. + * "fq=datasetType:dataset"). + * + * "datasetType": { + * "friendly": "Dataset Type", + * "labels": [ + * {"dataset":8}, + * {"software":1} + * ] + * } + * See also https://github.com/IQSS/dataverse-frontend/issues/809 + * and https://github.com/IQSS/dataverse/issues/11758 . + * + * We recognize that this will be a problem for internationalizing the SPA but + * the SPA will likely have similar problems with facets like publicationStatus + * where the labels are in English (e.g. {"Draft":42}). The Search API much use + * the English string when faceting (e.g. "fq=publicationStatus:Draft"). + */ + localefriendlyName = facetFieldCount.getName(); } else { try { - // This is where facets are capitalized. - // This will be a problem for the API clients because they get back a string like this from the Search API... - // {"datasetType":{"friendly":"Dataset Type","labels":[{"Dataset":1},{"Software":1}]} - // ... but they will need to use the lower case version (e.g. "software") to narrow results. localefriendlyName = BundleUtil.getStringFromPropertyFile(facetFieldCount.getName(), "Bundle"); } catch (Exception e) { localefriendlyName = facetFieldCount.getName(); diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java index 27b7a122c93..6b54e363923 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java @@ -358,6 +358,18 @@ public static JsonObjectBuilder json(Dataverse dv, Boolean hideEmail, Boolean re if (childCount != null) { bld.add("childCount", childCount); } + List allowedDatasetTypes = dv.getAllowedDatasetTypes(); + if (allowedDatasetTypes != null && !allowedDatasetTypes.isEmpty()) { + JsonArrayBuilder jab = Json.createArrayBuilder(); + for (DatasetType datasetType : allowedDatasetTypes) { + NullSafeJsonBuilder json = NullSafeJsonBuilder.jsonObjectBuilder() + .add("name", datasetType.getName()) + .add("displayName", datasetType.getDisplayName()) + .add("description", datasetType.getDescription()); + jab.add(json); + } + bld.add("allowedDatasetTypes", jab); + } addDatasetFileCountLimit(dv, bld); return bld; } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index f6c0054a43a..d6b94147f68 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -13,6 +13,7 @@ passwd=Password dataset=Dataset software=Software workflow=Workflow +review=Review # END dataset types datasets=Datasets newDataset=New Dataset diff --git a/src/main/java/propertyFiles/datasetTypes.properties b/src/main/java/propertyFiles/datasetTypes.properties new file mode 100644 index 00000000000..5e88b894f4a --- /dev/null +++ b/src/main/java/propertyFiles/datasetTypes.properties @@ -0,0 +1,9 @@ +# This file contains the strings for dataset types that can be +# translated into other languages (French, Spanish, etc.). +# Only the default dataset type (dataset) is included, as an example. +# If you add additional dataset types (e.g. software), you don't +# need to add them to this file if you are only running in English. +# However, if you are running in additional languages, you should +# add the additional dataset types to this file. +dataset.displayName=Dataset +dataset.description=A study, experiment, set of observations, or publication. A dataset can comprise a single file or multiple files. \ No newline at end of file diff --git a/src/main/java/propertyFiles/review.properties b/src/main/java/propertyFiles/review.properties new file mode 100644 index 00000000000..6799d5000d6 --- /dev/null +++ b/src/main/java/propertyFiles/review.properties @@ -0,0 +1,47 @@ +metadatablock.name=review +metadatablock.displayName=Review Metadata +metadatablock.displayFacet=Review +datasetfieldtype.itemReviewed.title=Item Reviewed +datasetfieldtype.itemReviewedUrl.title=URL +datasetfieldtype.itemReviewedType.title=Type +datasetfieldtype.itemReviewedCitation.title=Citation +datasetfieldtype.itemReviewed.description=The item being reviewed +datasetfieldtype.itemReviewedUrl.description=The URL of the item being reviewed +datasetfieldtype.itemReviewedType.description=The type of the item being reviewed +datasetfieldtype.itemReviewedCitation.description=The full bibliographic citation of the item being reviewed +datasetfieldtype.itemReviewed.watermark= +datasetfieldtype.itemReviewedUrl.watermark= +datasetfieldtype.itemReviewedType.watermark= +datasetfieldtype.itemReviewedCitation.watermark= +controlledvocabulary.itemReviewedType.audiovisual=Audiovisual +controlledvocabulary.itemReviewedType.award=Award +controlledvocabulary.itemReviewedType.book=Book +controlledvocabulary.itemReviewedType.book_chapter=Book Chapter +controlledvocabulary.itemReviewedType.collection=Collection +controlledvocabulary.itemReviewedType.computational_notebook=Computational Notebook +controlledvocabulary.itemReviewedType.conference_paper=Conference Paper +controlledvocabulary.itemReviewedType.conference_proceeding=Conference Proceeding +controlledvocabulary.itemReviewedType.datapaper=DataPaper +controlledvocabulary.itemReviewedType.dataset=Dataset +controlledvocabulary.itemReviewedType.dissertation=Dissertation +controlledvocabulary.itemReviewedType.event=Event +controlledvocabulary.itemReviewedType.image=Image +controlledvocabulary.itemReviewedType.interactive_resource=Interactive Resource +controlledvocabulary.itemReviewedType.instrument=Instrument +controlledvocabulary.itemReviewedType.journal=Journal +controlledvocabulary.itemReviewedType.journal_article=Journal Article +controlledvocabulary.itemReviewedType.model=Model +controlledvocabulary.itemReviewedType.output_management_plan=Output Management Plan +controlledvocabulary.itemReviewedType.peer_review=Peer Review +controlledvocabulary.itemReviewedType.physical_object=Physical Object +controlledvocabulary.itemReviewedType.preprint=Preprint +controlledvocabulary.itemReviewedType.project=Project +controlledvocabulary.itemReviewedType.report=Report +controlledvocabulary.itemReviewedType.service=Service +controlledvocabulary.itemReviewedType.software=Software +controlledvocabulary.itemReviewedType.sound=Sound +controlledvocabulary.itemReviewedType.standard=Standard +controlledvocabulary.itemReviewedType.study_registration=Study Registration +controlledvocabulary.itemReviewedType.text=Text +controlledvocabulary.itemReviewedType.workflow=Workflow +controlledvocabulary.itemReviewedType.other=Other diff --git a/src/main/resources/db/migration/V6.9.0.1.sql b/src/main/resources/db/migration/V6.9.0.1.sql new file mode 100644 index 00000000000..09cba0f6b93 --- /dev/null +++ b/src/main/resources/db/migration/V6.9.0.1.sql @@ -0,0 +1,8 @@ +-- Add displayname column to datasettype table +ALTER TABLE datasettype ADD COLUMN IF NOT EXISTS displayname VARCHAR(255) NOT NULL DEFAULT ''; +-- Set displayname for dataset +UPDATE datasettype SET displayname = 'Dataset' WHERE name = 'dataset'; +-- Add description column to datasettype table +ALTER TABLE datasettype ADD COLUMN IF NOT EXISTS description VARCHAR(255); +-- Set description for dataset +UPDATE datasettype SET description = 'A study, experiment, set of observations, or publication. A dataset can comprise a single file or multiple files.' WHERE name = 'dataset'; diff --git a/src/main/webapp/search-include-fragment.xhtml b/src/main/webapp/search-include-fragment.xhtml index 34ac72b3571..71c3775b833 100644 --- a/src/main/webapp/search-include-fragment.xhtml +++ b/src/main/webapp/search-include-fragment.xhtml @@ -381,6 +381,7 @@ + > dataset = with(getDatasetType.body().asString()).param("dataset", "dataset") + .getList("data.findAll { data -> data.name == dataset }"); + Map firstDataset = dataset.get(0); + assertEquals("Ensemble de données", firstDataset.get("displayName")); + + List> instrument = with(getDatasetType.body().asString()).param("instrument", "instrument") + .getList("data.findAll { data -> data.name == instrument }"); + Map firstInstrument = instrument.get(0); + // Instrument isn't translated in the French properties file; should fall back to English + assertEquals("Instrument", firstInstrument.get("displayName")); + } + } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java new file mode 100644 index 00000000000..225b53de393 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java @@ -0,0 +1,186 @@ +package edu.harvard.iq.dataverse.api; + +import edu.harvard.iq.dataverse.dataset.DatasetType; +import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; +import io.restassured.RestAssured; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import jakarta.json.Json; +import jakarta.json.JsonObjectBuilder; +import static jakarta.ws.rs.core.Response.Status.CREATED; +import static jakarta.ws.rs.core.Response.Status.OK; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class ReviewsIT { + + @BeforeAll + public static void setUpClass() { + RestAssured.baseURI = UtilIT.getRestAssuredBaseUri(); + + Response createUser = UtilIT.createRandomUser(); + createUser.then().assertThat().statusCode(OK.getStatusCode()); + String username = UtilIT.getUsernameFromResponse(createUser); + String apiToken = UtilIT.getApiTokenFromResponse(createUser); + UtilIT.setSuperuserStatus(username, true).then().assertThat().statusCode(OK.getStatusCode()); + + String datasetDescription = "A study, experiment, set of observations, or publication that is uploaded by a user. A dataset can comprise a single file or multiple files."; + ensureDatasetTypeIsPresent(DatasetType.DATASET_TYPE_DATASET, "Dataset", datasetDescription, apiToken); + + String reviewDescription = "A review of a dataset compiled by community experts."; + ensureDatasetTypeIsPresent(DatasetType.DATASET_TYPE_REVIEW, "Review", reviewDescription, apiToken); + } + + private static void ensureDatasetTypeIsPresent(String name, String displayName, String description, + String apiToken) { + Response getDatasetType = UtilIT.getDatasetType(name); + getDatasetType.prettyPrint(); + String nameFound = JsonPath.from(getDatasetType.getBody().asString()).getString("data.name"); + String displayNameFound = JsonPath.from(getDatasetType.getBody().asString()).getString("data.displayName"); + String descriptionFound = JsonPath.from(getDatasetType.getBody().asString()).getString("data.description"); + System.out.println("Found: name=" + nameFound + ". Display name=" + displayNameFound + ". Description=" + + descriptionFound); + if (name.equals(nameFound)) { + System.out.println(name + "=" + nameFound + ". Exists. No need to create. Returning."); + return; + } else { + System.out.println(name + " wasn't found. Create it."); + } + String jsonIn = NullSafeJsonBuilder.jsonObjectBuilder() + .add("name", name) + .add("displayName", displayName) + .add("description", description) + .build().toString(); + // System.out.println(JsonUtil.prettyPrint(jsonIn)); + Response typeAdded = UtilIT.addDatasetType(jsonIn, apiToken); + typeAdded.prettyPrint(); + typeAdded.then().assertThat().statusCode(OK.getStatusCode()); + } + + @Test + public void testCreateReview() { + + Response createUser = UtilIT.createRandomUser(); + createUser.prettyPrint(); + createUser.then().assertThat() + .statusCode(OK.getStatusCode()); + String username = UtilIT.getUsernameFromResponse(createUser); + String apiToken = UtilIT.getApiTokenFromResponse(createUser); + + Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken); + createDataverseResponse.prettyPrint(); + createDataverseResponse.then().assertThat() + .statusCode(CREATED.getStatusCode()); + + String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + + Response setMetadataBlocks = UtilIT.setMetadataBlocks(dataverseAlias, + Json.createArrayBuilder().add("citation").add("review"), apiToken); + setMetadataBlocks.prettyPrint(); + setMetadataBlocks.then().assertThat().statusCode(OK.getStatusCode()); + + String[] testInputLevelNames = { "itemReviewedUrl", "itemReviewedType" }; + boolean[] testRequiredInputLevels = { true, true }; + boolean[] testIncludedInputLevels = { true, true }; + Response updateDataverseInputLevelsResponse = UtilIT.updateDataverseInputLevels(dataverseAlias, + testInputLevelNames, testRequiredInputLevels, testIncludedInputLevels, apiToken); + updateDataverseInputLevelsResponse.prettyPrint(); + updateDataverseInputLevelsResponse.then().assertThat().statusCode(OK.getStatusCode()); + + String itemReviewedTitle = "Percent of Children That Have Asthma"; + String itemReviewedUrl = "https://datacommons.org/tools/statvar#sv=Percent_Person_Children_WithAsthma"; + String reviewTitle = "Review of " + itemReviewedTitle; + String authorName = "Wazowski, Mike"; + String authorEmail = "mwazowski@mailinator.com"; + JsonObjectBuilder jsonForCreatingReview = Json.createObjectBuilder() + /** + * See above where this type is added to the installation and + * therefore available for use. + */ + .add("datasetType", DatasetType.DATASET_TYPE_REVIEW) + .add("datasetVersion", Json.createObjectBuilder() + .add("license", Json.createObjectBuilder() + .add("name", "CC0 1.0") + .add("uri", "http://creativecommons.org/publicdomain/zero/1.0")) + .add("metadataBlocks", Json.createObjectBuilder() + .add("citation", Json.createObjectBuilder() + .add("fields", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("typeName", "title") + .add("value", reviewTitle) + .add("typeClass", "primitive") + .add("multiple", false)) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("authorName", + Json.createObjectBuilder() + .add("value", authorName) + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", + "authorName")))) + .add("typeClass", "compound") + .add("multiple", true) + .add("typeName", "author")) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("datasetContactEmail", + Json.createObjectBuilder() + .add("value", authorEmail) + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", + "datasetContactEmail")))) + .add("typeClass", "compound") + .add("multiple", true) + .add("typeName", "datasetContact")) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("dsDescriptionValue", + Json.createObjectBuilder() + .add("value", + "This is a review of a dataset.") + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", + "dsDescriptionValue")))) + .add("typeClass", "compound") + .add("multiple", true) + .add("typeName", "dsDescription")) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add("Medicine, Health and Life Sciences")) + .add("typeClass", "controlledVocabulary") + .add("multiple", true) + .add("typeName", "subject")) + .add(Json.createObjectBuilder() + .add("value", Json.createObjectBuilder() + .add("itemReviewedUrl", + Json.createObjectBuilder() + .add("value", itemReviewedUrl) + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", "itemReviewedUrl")) + .add("itemReviewedType", + Json.createObjectBuilder() + .add("value", "Dataset") + .add("typeClass", + "controlledVocabulary") + .add("multiple", false) + .add("typeName", "itemReviewedType"))) + .add("typeClass", "compound") + .add("multiple", false) + .add("typeName", "itemReviewed")))))); + + Response createReview = UtilIT.createDataset(dataverseAlias, jsonForCreatingReview, apiToken); + createReview.prettyPrint(); + createReview.then().assertThat().statusCode(CREATED.getStatusCode()); + Integer reviewId = UtilIT.getDatasetIdFromResponse(createReview); + String reviewPid = JsonPath.from(createReview.getBody().asString()).getString("data.persistentId"); + + } + +} diff --git a/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java b/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java index ca9e19e1bbf..04b552ded5f 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/SearchIT.java @@ -119,6 +119,7 @@ public void testSearchPermisions() { .body("data.total_count", CoreMatchers.is(1)) .body("data.count_in_response", CoreMatchers.is(1)) .body("data.items[0].name", CoreMatchers.is("Darwin's Finches")) + // Note that "Unpublished" and "Draft" are in English. That's how they are indexed. .body("data.items[0].publicationStatuses", CoreMatchers.hasItems("Unpublished", "Draft")) .statusCode(OK.getStatusCode()); diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java index 24ab8b56eff..2b2c6e0bc10 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -16,6 +16,7 @@ import jakarta.json.JsonObject; import static edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder.jsonObjectBuilder; +import static jakarta.ws.rs.core.HttpHeaders.ACCEPT_LANGUAGE; import static jakarta.ws.rs.core.Response.Status.CREATED; import java.nio.charset.StandardCharsets; @@ -4257,6 +4258,15 @@ static Response getDatasetVersionCitation(Integer datasetId, String version, boo return response; } + static Response getDatasetVersionCitationFormat(Integer datasetId, String version, boolean includeDeaccessioned, String format, String apiToken) { + Response response = given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .contentType("application/json") + .queryParam("includeDeaccessioned", includeDeaccessioned) + .get("/api/datasets/" + datasetId + "/versions/" + version + "/citation/" + format); + return response; + } + static Response setDatasetCitationDateField(String datasetIdOrPersistentId, String dateField, String apiToken) { String idInPath = datasetIdOrPersistentId; // Assume it's a number. String optionalQueryParam = ""; // If idOrPersistentId is a number we'll just put it in the path. @@ -4741,14 +4751,28 @@ static Response listDataverseInputLevels(String dataverseAlias, String apiToken) } public static Response getDatasetTypes() { - Response response = given() - .get("/api/datasets/datasetTypes"); - return response; + return getDatasetTypes(null); + } + + public static Response getDatasetTypes(String acceptLanguage) { + RequestSpecification requestSpecification = given(); + if (acceptLanguage != null) { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Language + requestSpecification.header(ACCEPT_LANGUAGE, acceptLanguage); + } + return requestSpecification.get("/api/datasets/datasetTypes"); } static Response getDatasetType(String idOrName) { - return given() - .get("/api/datasets/datasetTypes/" + idOrName); + return getDatasetType(idOrName, null); + } + + static Response getDatasetType(String idOrName, String acceptLanguage) { + RequestSpecification requestSpecification = given(); + if (acceptLanguage != null) { + requestSpecification.header(ACCEPT_LANGUAGE, acceptLanguage); + } + return requestSpecification.get("/api/datasets/datasetTypes/" + idOrName); } static Response addDatasetType(String jsonIn, String apiToken) { diff --git a/src/test/java/edu/harvard/iq/dataverse/i18n/I18NUtilTest.java b/src/test/java/edu/harvard/iq/dataverse/i18n/I18NUtilTest.java new file mode 100644 index 00000000000..10eadf3082b --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/i18n/I18NUtilTest.java @@ -0,0 +1,44 @@ +package edu.harvard.iq.dataverse.i18n; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import java.util.Locale; + +public class I18NUtilTest { + + @Test + void testParseAcceptLanguageHeader_singleLanguage() { + Locale locale = I18nUtil.parseAcceptLanguageHeader("en-US"); + assertEquals(Locale.forLanguageTag("en-US"), locale); + } + + @Test + void testParseAcceptLanguageHeader_singleLanguageWithQ() { + Locale locale = I18nUtil.parseAcceptLanguageHeader("en-US,en;q=0.5"); + assertEquals(Locale.forLanguageTag("en-US"), locale); + } + + @Test + void testParseAcceptLanguageHeader_multipleLanguages() { + Locale locale = I18nUtil.parseAcceptLanguageHeader("fr-CA,fr;q=0.8,en-US;q=0.6,en;q=0.4"); + assertEquals(Locale.forLanguageTag("fr-CA"), locale); + } + + @Test + void testParseAcceptLanguageHeader_emptyHeader() { + Locale locale = I18nUtil.parseAcceptLanguageHeader(""); + assertNull(locale); + } + + @Test + void testParseAcceptLanguageHeader_nullHeader() { + Locale locale = I18nUtil.parseAcceptLanguageHeader(null); + assertNull(locale); + } + + @Test + void testParseAcceptLanguageHeader_invalidHeader() { + Locale locale = I18nUtil.parseAcceptLanguageHeader("invalid-header"); + assertEquals(Locale.forLanguageTag("invalid-header"), locale); + } +} \ No newline at end of file