diff --git a/doc/release-notes/12313-local-reviews.md b/doc/release-notes/12313-local-reviews.md
new file mode 100644
index 00000000000..4ca22a784ff
--- /dev/null
+++ b/doc/release-notes/12313-local-reviews.md
@@ -0,0 +1,13 @@
+### Local Reviews
+
+Datasets can have local reviews, listable via API. A local review is a review dataset ("review" for short) that points at the URL form of a persistent ID of a dataset (e.g. itemReviewedUrl:https://doi.org/10.5072/FK2/ABCDEF) that is in the same Dataverse installation. Local reviews of a dataset can be listed via API (and we plan to build a UI for it some day).
+
+A new metadata block called "Trusted Data Dimensions and Intensities" has been added for testing. This is described in the setup instructions for review datasets.
+
+If you set `dataverse.feature.croissant-with-local-reviews` to true, local reviews will appear in the croissant and croissantSlim metadata export formats for any dataset that has local reviews. This feature is experiemental, which is why it is hidden behind a feature flag.
+
+See the guides for the new [list reviews](https://preview.guides.gdcc.io/en/develop/api/native-api.html#list-reviews) API endpoint, #12313, #12314, and #12425.
+
+## New Settings
+
+- dataverse.feature.croissant-with-local-reviews
diff --git a/doc/sphinx-guides/source/_static/api/list-reviews.json b/doc/sphinx-guides/source/_static/api/list-reviews.json
new file mode 100644
index 00000000000..c69b51aa793
--- /dev/null
+++ b/doc/sphinx-guides/source/_static/api/list-reviews.json
@@ -0,0 +1,52 @@
+{
+ "status": "OK",
+ "data": {
+ "reviews": [
+ {
+ "title": "Review of Pediatric Asthma",
+ "authors": [
+ "Wazowski, Mike"
+ ],
+ "persistentId": "doi:10.5072/FK2/1WD6BX",
+ "persistentIdUrl": "https://doi.org/10.5072/FK2/1WD6BX",
+ "id": 13,
+ "citation": "Wazowski, Mike, 2026, \"Review of Pediatric Asthma\", https://doi.org/10.5072/FK2/1WD6BX, Root, DRAFT VERSION",
+ "citationHtml": "Wazowski, Mike, 2026, \"Review of Pediatric Asthma\", https://doi.org/10.5072/FK2/1WD6BX, Root, DRAFT VERSION",
+ "datePublished": "",
+ "description": "This is a review of a dataset.",
+ "rubricMetadataBlocks": [
+ {
+ "name": "rubric_trusteddatadimensionsintensities",
+ "displayName": "Trusted Data Dimensions and Intensities",
+ "fields": [
+ {
+ "typeName": "licensingAndLegalClarity",
+ "value": "High"
+ },
+ {
+ "typeName": "authorAndProvenance",
+ "value": "Medium"
+ },
+ {
+ "typeName": "biasEquityAndRepresentativeness",
+ "value": "Low"
+ },
+ {
+ "typeName": "integrityAndUsability",
+ "value": "High"
+ },
+ {
+ "typeName": "fitnessForScopeAndContextualRelevance",
+ "value": "Medium"
+ },
+ {
+ "typeName": "transparencyOfMethodsAndDocumentation",
+ "value": "Low"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/doc/sphinx-guides/source/admin/dataverses-datasets.rst b/doc/sphinx-guides/source/admin/dataverses-datasets.rst
index 90bee375d9c..c5820422953 100644
--- a/doc/sphinx-guides/source/admin/dataverses-datasets.rst
+++ b/doc/sphinx-guides/source/admin/dataverses-datasets.rst
@@ -135,10 +135,15 @@ The Review metadata block gives you a few basic fields common to all reviews suc
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
+Instead of creating a new custom metadata block from scratch (if you simply want to evaluate the feature, for example), in a test environment, you can use the "Trusted Data Dimensions and Intensities" for testing. (A test environment is advised because metadata blocks cannot be deleted once they are loaded (https://github.com/IQSS/dataverse/issues/9628).) These are the files to download:
+
+- :download:`rubric_trusteddatadimensionsintensities.tsv <../../../../scripts/api/data/metadatablocks/rubric_trusteddatadimensionsintensities.tsv>`
+- :download:`rubric_trusteddatadimensionsintensities.properties <../../../../src/main/java/propertyFiles/rubric_trusteddatadimensionsintensities.properties>` (optional)
After loading the block, don't forget to update the Solr schema!
+As in the example above, the metadata block must start with ``rubric_`` (the "metadataBlock name" in the tsv itself) to be included in the output of the :ref:`api-list-reviews` API endpoint.
+
Create a Review Dataset Type
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/doc/sphinx-guides/source/admin/discoverability.rst b/doc/sphinx-guides/source/admin/discoverability.rst
index 3db42101e27..dd0c776726a 100644
--- a/doc/sphinx-guides/source/admin/discoverability.rst
+++ b/doc/sphinx-guides/source/admin/discoverability.rst
@@ -47,6 +47,8 @@ We include Croissant in the ``
`` because it's `recommended ``, which was the behavior in older versions of Dataverse, see :ref:`dataverse.legacy.schemaorg-in-html-head`.
+See also the :ref:`dataverse.feature.croissant-with-local-reviews` feature flag.
+
.. _discovery-sign-posting:
Signposting
diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst
index 1a1604886c6..78231886366 100644
--- a/doc/sphinx-guides/source/api/native-api.rst
+++ b/doc/sphinx-guides/source/api/native-api.rst
@@ -4835,6 +4835,31 @@ The fully expanded example above (without environment variables) looks like this
curl -H "X-Dataverse-key:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X PUT "https://demo.dataverse.org/api/datasets/3/license" -H "Content-type:application/json" --upload-file license.json
+.. _api-list-reviews:
+
+List Reviews
+~~~~~~~~~~~~
+
+Datasets can have reviews. Specifically, if a :ref:`review dataset ` points at (using the ``itemReviewedUrl`` field) the URL form of a persistent ID of a dataset (e.g. https://doi.org/10.5072/FK2/ABCDEF) that is in the same Dataverse installation as the review dataset, the review dataset will be included in the list of reviews for the dataset. It is considered a local review. If additional "rubric" metadata blocks are enabled (see :ref:`review-datasets-setup`) the "metadataBlock name" must start with ``rubric_`` for the fields to be included in the output of this API endpoint.
+
+An API token is optional if the review dataset has been published.
+
+.. 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/ABCDEF
+
+ curl -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/datasets/:persistentId/reviews?persistentId=$PERSISTENT_IDENTIFIER"
+
+The fully expanded example above (without environment variables) looks like this:
+
+.. code-block:: bash
+
+ curl -H "X-Dataverse-key:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" "https://demo.dataverse.org/api/datasets/:persistentId/reviews?persistentId=doi:10.5072/FK2/ABCDEF"
+
+:download:`list-reviews.json <../_static/api/list-reviews.json>` contains sample output of how the API response might look.
+
Files
-----
diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst
index f2a6fdfa324..f97c5e09d29 100644
--- a/doc/sphinx-guides/source/installation/config.rst
+++ b/doc/sphinx-guides/source/installation/config.rst
@@ -4127,6 +4127,13 @@ dataverse.feature.require-embargo-reason
Require an embargo reason when a user creates an embargo on one or more files. See :ref:`embargoes`.
+.. _dataverse.feature.croissant-with-local-reviews:
+
+dataverse.feature.croissant-with-local-reviews
+++++++++++++++++++++++++++++++++++++++++++++++
+
+Have the croissant and croissantSlim metadata export formats include an extra "reviews" array if local reviews exist. See :ref:`croissant-head`, :ref:`review-datasets-user`, :ref:`creating-a-review-dataset`, and :ref:`api-list-reviews`.
+
.. _:ApplicationServerSettings:
Application Server Settings
diff --git a/doc/sphinx-guides/source/user/dataset-management.rst b/doc/sphinx-guides/source/user/dataset-management.rst
index 2f7fb2c0d6e..5afa21a61e4 100755
--- a/doc/sphinx-guides/source/user/dataset-management.rst
+++ b/doc/sphinx-guides/source/user/dataset-management.rst
@@ -936,6 +936,8 @@ Review Datasets can only be created via API. You have the following options:
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`.
+If you point ``itemReviewedUrl`` at the URL form of a dataset (e.g. https://doi.org/10.5072/FK2/ABCDEF) that is in the same Dataverse installation as the review dataset, the review dataset is considered a local review and can be listed using the :ref:`api-list-reviews` API endpoint. These reviews appear in the Croissant metadata export if you enable the :ref:`dataverse.feature.croissant-with-local-reviews` feature flag.
+
.. _dataset-types-datacite:
Dataset Types and DataCite
diff --git a/scripts/api/data/metadatablocks/rubric_trusteddatadimensionsintensities.tsv b/scripts/api/data/metadatablocks/rubric_trusteddatadimensionsintensities.tsv
new file mode 100644
index 00000000000..80b31c6ffcc
--- /dev/null
+++ b/scripts/api/data/metadatablocks/rubric_trusteddatadimensionsintensities.tsv
@@ -0,0 +1,28 @@
+#metadataBlock name dataverseAlias displayName blockURI
+ rubric_trusteddatadimensionsintensities Trusted Data Dimensions and Intensities
+#datasetField name title description watermark fieldType displayOrder displayFormat advancedSearchField allowControlledVocabulary allowmultiples facetable displayoncreate required parent metadatablock_id termURI
+ authorAndProvenance Author and Provenance The level of trust in the data creators and in other provenance information text 1 TRUE TRUE FALSE TRUE FALSE FALSE rubric_trusteddatadimensionsintensities
+ integrityAndUsability Integrity and Usability The level of trust in the accuracy, completeness, and ease of use of the data text 2 TRUE TRUE FALSE TRUE FALSE FALSE rubric_trusteddatadimensionsintensities
+ fitnessForScopeAndContextualRelevance Fitness for Scope and Contextual Relevance The level of trust in the suitability of the data for specific contexts, questions, or policy applications text 3 TRUE TRUE FALSE TRUE FALSE FALSE rubric_trusteddatadimensionsintensities
+ licensingAndLegalClarity Licensing and Legal Clarity The level of trust in the explicitness of the data’s usage rights and their compliance with relevant laws and regulations text 4 TRUE TRUE FALSE TRUE FALSE FALSE rubric_trusteddatadimensionsintensities
+ transparencyOfMethodsAndDocumentation Transparency of Methods and Documentation The level of trust in the clarity of the descriptions of data collection and processing methods text 5 TRUE TRUE FALSE TRUE FALSE FALSE rubric_trusteddatadimensionsintensities
+ biasEquityAndRepresentativeness Bias, Equity, and Representativeness The level of trust in the inclusivity and fairness of the coverage of the data text 6 TRUE TRUE FALSE TRUE FALSE FALSE rubric_trusteddatadimensionsintensities
+#controlledVocabulary DatasetField Value identifier displayOrder
+ authorAndProvenance Low 0
+ authorAndProvenance Medium 1
+ authorAndProvenance High 2
+ integrityAndUsability Low 0
+ integrityAndUsability Medium 1
+ integrityAndUsability High 2
+ fitnessForScopeAndContextualRelevance Low 0
+ fitnessForScopeAndContextualRelevance Medium 1
+ fitnessForScopeAndContextualRelevance High 2
+ licensingAndLegalClarity Low 0
+ licensingAndLegalClarity Medium 1
+ licensingAndLegalClarity High 2
+ transparencyOfMethodsAndDocumentation Low 0
+ transparencyOfMethodsAndDocumentation Medium 1
+ transparencyOfMethodsAndDocumentation High 2
+ biasEquityAndRepresentativeness Low 0
+ biasEquityAndRepresentativeness Medium 1
+ biasEquityAndRepresentativeness High 2
diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java b/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java
index 20c16c5d9b4..b60633fca5f 100644
--- a/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java
+++ b/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java
@@ -32,6 +32,7 @@
import edu.harvard.iq.dataverse.engine.command.impl.DeleteDatasetVersionCommand;
import edu.harvard.iq.dataverse.engine.command.impl.DeletePrivateUrlCommand;
import edu.harvard.iq.dataverse.engine.command.impl.DestroyDatasetCommand;
+import edu.harvard.iq.dataverse.engine.command.impl.GetDatasetReviewsCommand;
import edu.harvard.iq.dataverse.engine.command.impl.GetPrivateUrlCommand;
import edu.harvard.iq.dataverse.engine.command.impl.LinkDatasetCommand;
import edu.harvard.iq.dataverse.engine.command.impl.PublishDatasetCommand;
@@ -104,6 +105,7 @@
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.json.Json;
+import jakarta.json.JsonObject;
import jakarta.json.JsonObjectBuilder;
import jakarta.persistence.OptimisticLockException;
@@ -133,6 +135,7 @@
import edu.harvard.iq.dataverse.externaltools.ExternalToolServiceBean;
import edu.harvard.iq.dataverse.globus.GlobusServiceBean;
import edu.harvard.iq.dataverse.export.SchemaDotOrgExporter;
+import edu.harvard.iq.dataverse.export.croissant.CroissantExportUtil;
import edu.harvard.iq.dataverse.externaltools.ExternalToolHandler;
import edu.harvard.iq.dataverse.license.License;
import edu.harvard.iq.dataverse.makedatacount.MakeDataCountLoggingServiceBean;
@@ -6110,6 +6113,20 @@ public String getCroissant() {
final String CROISSANT_SCHEMA_NAME = "croissantSlim";
ExportService instance = ExportService.getInstance();
String croissant = instance.getLatestPublishedAsString(dataset, CROISSANT_SCHEMA_NAME);
+ if (FeatureFlags.CROISSANT_WITH_LOCAL_REVIEWS.enabled()) {
+ // Rewrite the export on the fly and insert local reviews until we have a solution for https://github.com/gdcc/dataverse-spi/issues/5
+ JsonObjectBuilder reviewsJsonObj = null;
+ try {
+ reviewsJsonObj = commandEngine.submit(new GetDatasetReviewsCommand(dvRequestService.getDataverseRequest(), dataset));
+ JsonObjectBuilder reviews = CroissantExportUtil.getReviews(reviewsJsonObj);
+ JsonObject croissantJson = JsonUtil.getJsonObject(croissant);
+ String updatedContent = Json.createObjectBuilder(croissantJson)
+ .add("reviews", reviews.build().getJsonArray("reviews")).build().toString();
+ return updatedContent;
+ } catch (CommandException e) {
+ logger.fine("Couldn't get reviews");
+ }
+ }
if (croissant != null && !croissant.isEmpty()) {
logger.fine("Returning cached CROISSANT.");
return croissant;
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 136b6dbb69b..5bedd978f39 100644
--- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java
+++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java
@@ -36,6 +36,7 @@
import edu.harvard.iq.dataverse.engine.command.exception.UnforcedCommandException;
import edu.harvard.iq.dataverse.engine.command.impl.*;
import edu.harvard.iq.dataverse.export.ExportService;
+import edu.harvard.iq.dataverse.export.croissant.CroissantExportUtil;
import edu.harvard.iq.dataverse.externaltools.ExternalTool;
import edu.harvard.iq.dataverse.externaltools.ExternalToolHandler;
import edu.harvard.iq.dataverse.globus.GlobusServiceBean;
@@ -87,9 +88,11 @@
import org.glassfish.jersey.media.multipart.FormDataParam;
import software.amazon.awssdk.services.s3.model.CompletedPart;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
+import java.nio.charset.StandardCharsets;
import java.sql.Timestamp;
import java.text.MessageFormat;
import java.text.SimpleDateFormat;
@@ -275,6 +278,17 @@ public Response exportDataset(@Context ContainerRequestContext crc, @QueryParam(
ExportService instance = ExportService.getInstance();
InputStream is = instance.getExport(datasetVersion, exporter);
+ if (FeatureFlags.CROISSANT_WITH_LOCAL_REVIEWS.enabled()
+ && (exporter.equals("croissant") || exporter.equals("croissantSlim"))) {
+ // Rewrite the export on the fly and insert local reviews until we have a solution for https://github.com/gdcc/dataverse-spi/issues/5
+ JsonObjectBuilder reviews = CroissantExportUtil
+ .getReviews(commandEngine.submit(new GetDatasetReviewsCommand(req, dataset)));
+ String content = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+ JsonObject croissantJson = JsonUtil.getJsonObject(content);
+ String updatedContent = Json.createObjectBuilder(croissantJson)
+ .add("reviews", reviews.build().getJsonArray("reviews")).build().toString();
+ is = new ByteArrayInputStream(updatedContent.getBytes(StandardCharsets.UTF_8));
+ }
String mediaType = instance.getMediaType(exporter);
@@ -6261,7 +6275,23 @@ public Response updateLicense(@Context ContainerRequestContext crc,
}
}, getRequestUser(crc));
}
-
+
+ @GET
+ @AuthRequired
+ @Path("{identifier}/reviews")
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response getReviews(@Context ContainerRequestContext crc, @PathParam("identifier") String id) {
+ return response(req -> {
+ Dataset dataset = findDatasetOrDie(id);
+ try {
+ JsonObjectBuilder job = execCommand(new GetDatasetReviewsCommand(req, dataset));
+ return ok(job);
+ } catch (Exception ex) {
+ return error(BAD_REQUEST, ex.getMessage());
+ }
+ }, getRequestUser(crc));
+ }
+
/**
* Storage quotas and use. Note that these methods replicate the
* collection-level equivalents 1:1. Both the quotas and the system for
diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetReviewsCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetReviewsCommand.java
new file mode 100644
index 00000000000..582b890777e
--- /dev/null
+++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetReviewsCommand.java
@@ -0,0 +1,134 @@
+package edu.harvard.iq.dataverse.engine.command.impl;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import edu.harvard.iq.dataverse.Dataset;
+import edu.harvard.iq.dataverse.Dataverse;
+import edu.harvard.iq.dataverse.MetadataBlock;
+import edu.harvard.iq.dataverse.authorization.Permission;
+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.exception.CommandException;
+import edu.harvard.iq.dataverse.search.SearchConstants;
+import edu.harvard.iq.dataverse.search.SearchException;
+import edu.harvard.iq.dataverse.search.SearchFields;
+import edu.harvard.iq.dataverse.search.SolrQueryResponse;
+import edu.harvard.iq.dataverse.search.SolrSearchResult;
+import edu.harvard.iq.dataverse.search.SortBy;
+import jakarta.json.Json;
+import jakarta.json.JsonArray;
+import jakarta.json.JsonArrayBuilder;
+import jakarta.json.JsonObject;
+import jakarta.json.JsonObjectBuilder;
+import jakarta.json.JsonValue;
+
+// No annotations here since permissions are dynamically decided
+public class GetDatasetReviewsCommand extends AbstractCommand {
+
+ private final Dataset dataset;
+
+ public GetDatasetReviewsCommand(DataverseRequest request, Dataset target) {
+ super(request, target);
+ dataset = target;
+ }
+
+ @Override
+ public JsonObjectBuilder execute(CommandContext ctxt) throws CommandException {
+ JsonObjectBuilder reviews = Json.createObjectBuilder();
+ List dataverses = new ArrayList<>();
+ // Putting PID as URL in quotes to avoid hits we don't want
+ String query = "itemReviewedUrl:\"" + dataset.getGlobalId().asURL() + "\"";
+ List filterQueries = new ArrayList<>();
+ // Limit to datasets (review datasets)
+ filterQueries.add(SearchFields.TYPE + ":" + SearchConstants.DATASETS);
+ String sortField = SearchFields.ID;
+ String sortOrder = SortBy.ASCENDING;
+ int paginationStart = 0;
+ boolean dataRelatedToMe = false;
+ // We only expect a handful of reviews. This should be plenty.
+ int numResultsPerPage = 100;
+ try {
+ SolrQueryResponse solrQueryResponse = ctxt.search().getDefaultSearchService().search(getRequest(),
+ dataverses, query, filterQueries, sortField, sortOrder, paginationStart, dataRelatedToMe,
+ numResultsPerPage);
+ JsonArrayBuilder itemsArrayBuilder = Json.createArrayBuilder();
+ List solrSearchResults = solrQueryResponse.getSolrSearchResults();
+ for (SolrSearchResult solrSearchResult : solrSearchResults) {
+ // Construct a JSON object intentionally rather than simply returning the
+ // solrSearchResult. This "get reviews" command may be powered by a
+ // database query in the future and we'll want to preserve the contract
+ // we're establishing here if we make the switch from Solr.
+ List metadataFields = new ArrayList<>();
+ // Retrieve only the blocks that are rubrics, that have fields for reviews
+ for (MetadataBlock mdb : ctxt.metadataBlocks().listMetadataBlocks()) {
+ String name = mdb.getName();
+ if (name.startsWith("rubric_")) {
+ metadataFields.add(name + ":*");
+ }
+ }
+ JsonObjectBuilder searchResultBuilder = solrSearchResult.json(false, true, false, metadataFields);
+ JsonObject searchResultObject = searchResultBuilder.build();
+ String title = searchResultObject.getString("name");
+ JsonArray authors = searchResultObject.getJsonArray("authors");
+ String citation = searchResultObject.getString("citation");
+ String citationHtml = searchResultObject.getString("citationHtml");
+ String pid = searchResultObject.getString("global_id");
+ String pidUrl = searchResultObject.getString("url");
+ long id = searchResultObject.getJsonNumber("entity_id").longValue();
+ // Drafts don't have a published timestamp
+ String datePublished = searchResultObject.getString("published_at", "");
+ String description = searchResultObject.getString("description");
+ JsonObject rubricMetadataBlocksFromSolr = searchResultObject.getJsonObject("metadataBlocks");
+ JsonArrayBuilder rubricMetadataBlocks = Json.createArrayBuilder();
+ for (String key : rubricMetadataBlocksFromSolr.keySet()) {
+ String displayName = rubricMetadataBlocksFromSolr.getJsonObject(key).getString("displayName");
+ JsonArray fieldsFromJson = rubricMetadataBlocksFromSolr.getJsonObject(key).getJsonArray("fields");
+ JsonObjectBuilder block = Json.createObjectBuilder();
+ block.add("name", key);
+ block.add("displayName", displayName);
+ JsonArrayBuilder fieldAccumulator = Json.createArrayBuilder();
+ for (JsonValue fieldJsonValue : fieldsFromJson) {
+ JsonObject fieldObject = fieldJsonValue.asJsonObject();
+ String typeName = fieldObject.getString("typeName");
+ String value = fieldObject.getString("value");
+ JsonObjectBuilder fieldToAdd = Json.createObjectBuilder();
+ fieldToAdd.add("typeName", typeName);
+ fieldToAdd.add("value", value);
+ fieldAccumulator.add(fieldToAdd);
+ }
+ block.add("fields", fieldAccumulator);
+ rubricMetadataBlocks.add(block);
+ }
+ JsonObjectBuilder review = Json.createObjectBuilder()
+ .add("title", title)
+ .add("authors", authors)
+ .add("persistentId", pid)
+ .add("persistentIdUrl", pidUrl)
+ .add("id", id)
+ .add("citation", citation)
+ .add("citationHtml", citationHtml)
+ .add("datePublished", datePublished)
+ .add("description", description)
+ .add("rubricMetadataBlocks", rubricMetadataBlocks);
+ itemsArrayBuilder.add(review);
+ }
+ reviews.add("reviews", itemsArrayBuilder);
+ } catch (SearchException ex) {
+ throw new CommandException(ex.getMessage(), this);
+ }
+ return reviews;
+ }
+
+ @Override
+ public Map> getRequiredPermissions() {
+ return Collections.singletonMap("",
+ dataset.isReleased() ? Collections.emptySet()
+ : Collections.singleton(Permission.ViewUnpublishedDataset));
+ }
+
+}
diff --git a/src/main/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtil.java b/src/main/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtil.java
index 694f077ab1f..06b249552d5 100644
--- a/src/main/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtil.java
+++ b/src/main/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtil.java
@@ -16,6 +16,8 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+
+import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.StringEscapeUtils;
// Validate with src/test/resources/croissant/validate.sh
@@ -563,4 +565,97 @@ private static String getNumericType(String variableIntervalType) {
default -> "sc:Text";
};
}
+
+ // This "reviews" object is modeled off slide 10 of
+ // https://docs.google.com/presentation/d/1dQinlXazxq3XLtzUNpG2-l0MAXQQArQU9PQ20uhRYVU/edit?slide=id.g3dfc9e1fbe0_0_127#slide=id.g3dfc9e1fbe0_0_127
+ // which looks like this:
+ // "reviews": [{
+ // "@context": "https://schema.org/",
+ // "@type": "CriticReview",
+ // "itemReviewed": {
+ // "@type": "Dataset",
+ // "name": "Dataset"
+ // },
+ // "author": {
+ // "@type": "Organization",
+ // "name": "Association of Data Reusers"
+ // },
+ // "positiveNotes":
+ // {
+ // "@type": "ItemList",
+ // "itemListElement": [
+ // {
+ // "@type": "StructuredValue",
+ // "position": 1,
+ // "name": "The dataset is well-documented and easy to understand.",
+ // "value":{
+ // "@type": "QuantitativeValue",
+ // "value": 10,
+ // "minValue": 0,
+ // "maxValue": 10
+ // }
+ // },
+ // ]
+ // }]
+ public static JsonObjectBuilder getReviews(JsonObjectBuilder reviewsIn) {
+ JsonObjectBuilder reviewsOut = Json.createObjectBuilder();
+ JsonArrayBuilder jab = Json.createArrayBuilder();
+ JsonArray reviews = reviewsIn.build().getJsonArray("reviews");
+
+ for (JsonValue jsonValue : reviews) {
+ JsonObject jsonObject = (JsonObject) jsonValue;
+ String title = jsonObject.getString("title");
+ JsonArray authors = jsonObject.getJsonArray("authors");
+ JsonArrayBuilder creators = Json.createArrayBuilder();
+ for (JsonValue author : authors) {
+ JsonObjectBuilder job = Json.createObjectBuilder();
+ // TODO add @type for "Person" or "Organization"
+ job.add("name", author);
+ creators.add(job);
+ }
+ String datePublished = jsonObject.getString("datePublished");
+ JsonObjectBuilder positiveNotesObj = Json.createObjectBuilder();
+ positiveNotesObj.add("@type", "ItemList");
+ JsonArrayBuilder positiveNotesArray = Json.createArrayBuilder();
+ JsonArray rubricMetadataBlocks = jsonObject.getJsonArray("rubricMetadataBlocks");
+ for (JsonValue rmb : rubricMetadataBlocks) {
+ JsonObject rubricMetadataBlock = rmb.asJsonObject();
+ JsonArray fields = rubricMetadataBlock.getJsonArray("fields");
+ for (JsonValue fieldJsonValue : fields) {
+ JsonObject field = fieldJsonValue.asJsonObject();
+ String typeName = field.getString("typeName");
+ String value = field.getString("value");
+ // Flatten all positive notes into a single array, regardless of which block
+ // they came from.
+ positiveNotesArray.add(Json.createObjectBuilder()
+ .add("@type", "StructuredValue")
+ .add("name", typeName)
+ .add("value", Json.createObjectBuilder()
+ .add("@type", StringUtils.isNumeric(value) ? "QuantitativeValue" : "QualitativeValue")
+ // We are aware that the value might be "Low", which is a bit strange for a positive note! We are constrained by what's allowed by https://schema.org/CriticReview
+ .add("value", value)));
+ }
+ }
+ positiveNotesObj.add("itemListElement", positiveNotesArray);
+ jab.add(
+ Json.createObjectBuilder()
+ .add("@context", "https://schema.org/")
+ .add("@type", "CriticReview")
+ .add(
+ "itemReviewed",
+ Json.createObjectBuilder()
+ // TODO don't hard code this to "Dataset"
+ .add("@type", "Dataset")
+ .add("name", title))
+ // We use "creator" instead of "author" here for consistency with Croissant.
+ .add("creator", creators)
+ .add("positiveNotes", positiveNotesObj)
+ // TODO Instead of "" consider not emitting datePublished when we don't have a date to show.
+ .add("datePublished", datePublished != null && datePublished != "" ? datePublished : "")
+ .add("reviewBody", jsonObject.getString("description"))
+ .add("id", jsonObject.getJsonNumber("id")));
+ }
+ reviewsOut.add("reviews", jab);
+ return reviewsOut;
+ }
}
diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java
index e1c7e69f7db..2e6be614e5e 100644
--- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java
+++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java
@@ -254,6 +254,12 @@ public enum FeatureFlags {
* flag makes a reason required, both in the UI and API.
*/
REQUIRE_EMBARGO_REASON("require-embargo-reason"),
+
+ /**
+ * The croissant and croissantSlim metadata export formats can include an extra
+ * "reviews" array if local reviews exist.
+ */
+ CROISSANT_WITH_LOCAL_REVIEWS("croissant-with-local-reviews"),
;
final String flag;
diff --git a/src/main/java/propertyFiles/rubric_trusteddatadimensionsintensities.properties b/src/main/java/propertyFiles/rubric_trusteddatadimensionsintensities.properties
new file mode 100644
index 00000000000..e3a9c66b2a9
--- /dev/null
+++ b/src/main/java/propertyFiles/rubric_trusteddatadimensionsintensities.properties
@@ -0,0 +1,41 @@
+metadatablock.name=trusteddatadimensionsintensities
+metadatablock.displayName=Trusted Data Dimensions and Intensities
+metadatablock.displayFacet=
+datasetfieldtype.authorAndProvenance.title=Author and Provenance
+datasetfieldtype.integrityAndUsability.title=Integrity and Usability
+datasetfieldtype.fitnessForScopeAndContextualRelevance.title=Fitness for Scope and Contextual Relevance
+datasetfieldtype.licensingAndLegalClarity.title=Licensing and Legal Clarity
+datasetfieldtype.transparencyOfMethodsAndDocumentation.title=Transparency of Methods and Documentation
+datasetfieldtype.biasEquityAndRepresentativeness.title=Bias, Equity, and Representativeness
+datasetfieldtype.reviewTarget.description=The type of research object reviewed
+datasetfieldtype.authorAndProvenance.description=The level of trust in the data creators and in other provenance information
+datasetfieldtype.integrityAndUsability.description=The level of trust in the accuracy, completeness, and ease of use of the data
+datasetfieldtype.fitnessForScopeAndContextualRelevance.description=The level of trust in the suitability of the data for specific contexts, questions, or policy applications
+datasetfieldtype.licensingAndLegalClarity.description=The level of trust in the explicitness of the data’s usage rights and their compliance with relevant laws and regulations
+datasetfieldtype.transparencyOfMethodsAndDocumentation.description=The level of trust in the clarity of the descriptions of data collection and processing methods
+datasetfieldtype.biasEquityAndRepresentativeness.description=The level of trust in the inclusivity and fairness of the coverage of the data
+datasetfieldtype.reviewTarget.watermark=
+datasetfieldtype.authorAndProvenance.watermark=
+datasetfieldtype.integrityAndUsability.watermark=
+datasetfieldtype.fitnessForScopeAndContextualRelevance.watermark=
+datasetfieldtype.licensingAndLegalClarity.watermark=
+datasetfieldtype.transparencyOfMethodsAndDocumentation.watermark=
+datasetfieldtype.biasEquityAndRepresentativeness.watermark=
+controlledvocabulary.authorAndProvenance.low=Low
+controlledvocabulary.authorAndProvenance.medium=Medium
+controlledvocabulary.authorAndProvenance.high=High
+controlledvocabulary.integrityAndUsability.low=Low
+controlledvocabulary.integrityAndUsability.medium=Medium
+controlledvocabulary.integrityAndUsability.high=High
+controlledvocabulary.fitnessForScopeAndContextualRelevance.low=Low
+controlledvocabulary.fitnessForScopeAndContextualRelevance.medium=Medium
+controlledvocabulary.fitnessForScopeAndContextualRelevance.high=High
+controlledvocabulary.licensingAndLegalClarity.low=Low
+controlledvocabulary.licensingAndLegalClarity.medium=Medium
+controlledvocabulary.licensingAndLegalClarity.high=High
+controlledvocabulary.transparencyOfMethodsAndDocumentation.low=Low
+controlledvocabulary.transparencyOfMethodsAndDocumentation.medium=Medium
+controlledvocabulary.transparencyOfMethodsAndDocumentation.high=High
+controlledvocabulary.biasEquityAndRepresentativeness.low=Low
+controlledvocabulary.biasEquityAndRepresentativeness.medium=Medium
+controlledvocabulary.biasEquityAndRepresentativeness.high=High
\ No newline at end of file
diff --git a/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java
index 2fc74c4b3f1..5dd977b301f 100644
--- a/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java
+++ b/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java
@@ -1,6 +1,9 @@
package edu.harvard.iq.dataverse.api;
+import static edu.harvard.iq.dataverse.api.ApiConstants.DS_VERSION_LATEST_PUBLISHED;
+
import edu.harvard.iq.dataverse.dataset.DatasetType;
+import edu.harvard.iq.dataverse.util.StringUtil;
import edu.harvard.iq.dataverse.util.json.JsonUtil;
import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder;
import io.restassured.RestAssured;
@@ -11,12 +14,14 @@
import static jakarta.ws.rs.core.Response.Status.CREATED;
import static jakarta.ws.rs.core.Response.Status.OK;
import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.startsWith;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
+import org.hamcrest.Matchers;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -63,6 +68,21 @@ public static void setUpClass() {
response.then().assertThat().statusCode(OK.getStatusCode());
}
+ byte[] rubric1Tsv = null;
+ try {
+ rubric1Tsv = Files.readAllBytes(Paths.get("scripts/api/data/metadatablocks/rubric_trusteddatadimensionsintensities.tsv"));
+ } catch (IOException e) {
+ }
+
+ // See warnings above. If you enable this, don't forget to update Solr.
+ boolean loadRubric1Tsv = false;
+ if (loadRubric1Tsv) {
+ Response response = UtilIT.loadMetadataBlock(apiTokenSuperuser, rubric1Tsv);
+ response.prettyPrint();
+ assertEquals(200, response.getStatusCode());
+ response.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, apiTokenSuperuser);
@@ -344,4 +364,314 @@ public void testCreateReviewRequiredFields() {
}
+ @Test
+ public void testLocalReviews() {
+
+ Response createUserDatasetAuthor = UtilIT.createRandomUser();
+ createUserDatasetAuthor.prettyPrint();
+ createUserDatasetAuthor.then().assertThat()
+ .statusCode(OK.getStatusCode());
+ String usernameDatasetAuthor = UtilIT.getUsernameFromResponse(createUserDatasetAuthor);
+ String apiTokenDatasetAuthor = UtilIT.getApiTokenFromResponse(createUserDatasetAuthor);
+
+ Response createCollectionOfDatasets = UtilIT.createRandomDataverse(apiTokenDatasetAuthor);
+ createCollectionOfDatasets.prettyPrint();
+ createCollectionOfDatasets.then().assertThat()
+ .statusCode(CREATED.getStatusCode());
+
+ String collectionAliasDatasets = UtilIT.getAliasFromResponse(createCollectionOfDatasets);
+ String datasetJson = """
+ {
+ "http://purl.org/dc/terms/title": "Pediatric Asthma",
+ "http://purl.org/dc/terms/creator": {
+ "https://dataverse.org/schema/citation/authorName": "Sullivan, James"
+ },
+ "https://dataverse.org/schema/citation/datasetContact": {
+ "https://dataverse.org/schema/citation/datasetContactEmail": "sully@mailinator.com"
+ },
+ "https://dataverse.org/schema/citation/dsDescription": {
+ "https://dataverse.org/schema/citation/dsDescriptionValue": "A dataset about pediatric asthma."
+ },
+ "http://purl.org/dc/terms/subject": "Medicine, Health and Life Sciences"
+ }
+ """;
+
+ Response createDataset = UtilIT.createDatasetSemantic(collectionAliasDatasets, datasetJson,
+ apiTokenDatasetAuthor);
+ createDataset.prettyPrint();
+ createDataset.then().assertThat().statusCode(CREATED.getStatusCode());
+
+ Integer datasetId = UtilIT.getDatasetIdFromResponse(createDataset);
+ String datasetPid = UtilIT.getDatasetPersistentIdFromResponse(createDataset);
+
+ Response setLicensetoCC0 = UtilIT.updateLicense(datasetId.toString(), "{ \"name\": \"CC0 1.0\" }",
+ apiTokenDatasetAuthor);
+ setLicensetoCC0.prettyPrint();
+ setLicensetoCC0.then().assertThat().statusCode(OK.getStatusCode());
+
+ Response publishCollection = UtilIT.publishDataverseViaNativeApi(collectionAliasDatasets,
+ apiTokenDatasetAuthor);
+ // publishCollection.prettyPrint();
+ publishCollection.then().assertThat()
+ .statusCode(OK.getStatusCode());
+
+ Response publishDataset = UtilIT.publishDatasetViaNativeApi(datasetId, "major", apiTokenDatasetAuthor);
+ publishDataset.prettyPrint();
+ publishDataset.then().assertThat()
+ .statusCode(OK.getStatusCode());
+
+ Response exportDatasetNoReviews = UtilIT.exportDataset(datasetPid, "croissant");
+ exportDatasetNoReviews.prettyPrint();
+ exportDatasetNoReviews.then().assertThat()
+ .statusCode(OK.getStatusCode());
+ // .body("reviews", Matchers.nullValue());
+
+ Response createUserReviewer = UtilIT.createRandomUser();
+ createUserReviewer.prettyPrint();
+ createUserReviewer.then().assertThat()
+ .statusCode(OK.getStatusCode());
+ String usernameReviewer = UtilIT.getUsernameFromResponse(createUserReviewer);
+ String apiTokenReviewer = UtilIT.getApiTokenFromResponse(createUserReviewer);
+
+ Response getDataset = UtilIT.nativeGetUsingPersistentId(datasetPid, apiTokenReviewer);
+ getDataset.prettyPrint();
+ getDataset.then().assertThat().statusCode(OK.getStatusCode());
+
+ String datasetPersistentUrl = JsonPath.from(getDataset.body().asString()).getString("data.persistentUrl");
+ String datasetTitle = JsonPath.from(getDataset.body().asString())
+ .getString("data.latestVersion.metadataBlocks.citation.fields[0].value");
+
+ Response getCitation = UtilIT.getDatasetVersionCitation(datasetId, DS_VERSION_LATEST_PUBLISHED, false,
+ apiTokenReviewer);
+ getCitation.prettyPrint();
+ getCitation.then().assertThat().statusCode(OK.getStatusCode());
+ String datasetCitationHtml = JsonPath.from(getCitation.getBody().asString()).getString("data.message");
+ String datasetCitationText = StringUtil.html2text(datasetCitationHtml);
+
+ Response createCollectionOfReviews = UtilIT.createRandomDataverse(apiTokenReviewer);
+ createCollectionOfReviews.prettyPrint();
+ createCollectionOfReviews.then().assertThat()
+ .statusCode(CREATED.getStatusCode());
+
+ String collectionAliasReviews = UtilIT.getAliasFromResponse(createCollectionOfReviews);
+
+ Response setAllowedDatasetTypes = UtilIT.setCollectionAttribute(collectionAliasReviews, "allowedDatasetTypes",
+ "review", apiTokenSuperuser);
+ setAllowedDatasetTypes.prettyPrint();
+ setAllowedDatasetTypes.then().assertThat()
+ .statusCode(OK.getStatusCode())
+ .body("data.allowedDatasetTypes[0].name", is("review"))
+ .body("data.allowedDatasetTypes[0].displayName", is("Review"))
+ .body("data.allowedDatasetTypes[0].description",
+ is("A review of a dataset compiled by the expert community."));
+
+ Response setMetadataBlocks = UtilIT.setMetadataBlocks(collectionAliasReviews, Json.createArrayBuilder().add("citation").add("rubric_trusteddatadimensionsintensities"), apiTokenReviewer);
+ setMetadataBlocks.prettyPrint();
+ setMetadataBlocks.then().assertThat().statusCode(OK.getStatusCode());
+
+ String itemReviewedTitle = datasetTitle;
+ String itemReviewedUrl = datasetPersistentUrl;
+ String itemReviewedCitation = datasetCitationHtml;
+ 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("itemReviewedCitation",
+ Json.createObjectBuilder()
+ .add("value", itemReviewedCitation)
+ .add("typeClass", "primitive")
+ .add("multiple", false)
+ .add("typeName",
+ "itemReviewedCitation")))
+ .add("typeClass", "compound")
+ .add("multiple", false)
+ .add("typeName", "itemReviewed"))))
+ .add("rubric_trusteddatadimensionsintensities", Json.createObjectBuilder()
+ .add("fields", Json.createArrayBuilder()
+ .add(Json.createObjectBuilder()
+ .add("typeName", "authorAndProvenance")
+ .add("value", "Medium")
+ .add("typeClass", "controlledVocabulary")
+ .add("multiple", false))
+ .add(Json.createObjectBuilder()
+ .add("typeName", "integrityAndUsability")
+ .add("value", "High")
+ .add("typeClass", "controlledVocabulary")
+ .add("multiple", false))
+ .add(Json.createObjectBuilder()
+ .add("typeName", "fitnessForScopeAndContextualRelevance")
+ .add("value", "Medium")
+ .add("typeClass", "controlledVocabulary")
+ .add("multiple", false))
+ .add(Json.createObjectBuilder()
+ .add("typeName", "licensingAndLegalClarity")
+ .add("value", "High")
+ .add("typeClass", "controlledVocabulary")
+ .add("multiple", false))
+ .add(Json.createObjectBuilder()
+ .add("typeName", "transparencyOfMethodsAndDocumentation")
+ .add("value", "Low")
+ .add("typeClass", "controlledVocabulary")
+ .add("multiple", false))
+ .add(Json.createObjectBuilder()
+ .add("typeName", "biasEquityAndRepresentativeness")
+ .add("value", "Low")
+ .add("typeClass", "controlledVocabulary")
+ .add("multiple", false))))));
+
+ Response createReview = UtilIT.createDataset(collectionAliasReviews, jsonForCreatingReview, apiTokenReviewer);
+ createReview.prettyPrint();
+ createReview.then().assertThat().statusCode(CREATED.getStatusCode());
+ Integer reviewId = UtilIT.getDatasetIdFromResponse(createReview);
+ String reviewPid = JsonPath.from(createReview.getBody().asString()).getString("data.persistentId");
+
+ UtilIT.sleepForReindex(String.valueOf(datasetId), apiTokenReviewer, 5);
+
+ Response getReviewsPrePubReviewer = UtilIT.getReviews(datasetPid, apiTokenReviewer);
+ getReviewsPrePubReviewer.prettyPrint();
+ getReviewsPrePubReviewer.then().assertThat()
+ .statusCode(OK.getStatusCode())
+ .body("data.reviews[0].title", is(reviewTitle))
+ .body("data.reviews[0].persistentId", is(reviewPid))
+ .body("data.reviews[0].datePublished", is(""))
+ .body("data.reviews[0].id", is(reviewId));
+
+ Response getReviewsPrePubGuest = UtilIT.getReviews(datasetPid);
+ getReviewsPrePubGuest.prettyPrint();
+ getReviewsPrePubGuest.then().assertThat()
+ .statusCode(OK.getStatusCode())
+ .body("data.reviews", Matchers.empty());
+
+ Response getReviewsPrePubDatasetAuthor = UtilIT.getReviews(datasetPid, apiTokenDatasetAuthor);
+ getReviewsPrePubDatasetAuthor.prettyPrint();
+ getReviewsPrePubDatasetAuthor.then().assertThat()
+ .statusCode(OK.getStatusCode())
+ .body("data.reviews", Matchers.empty());
+
+ Response publishCollectionReviews = UtilIT.publishDataverseViaNativeApi(collectionAliasReviews,
+ apiTokenReviewer);
+ publishCollectionReviews.then().assertThat()
+ .statusCode(OK.getStatusCode());
+
+ Response publishReview = UtilIT.publishDatasetViaNativeApi(reviewId, "major", apiTokenReviewer);
+ publishReview.then().assertThat()
+ .statusCode(OK.getStatusCode());
+
+ // Putting PID as URL in quotes to avoid hits we don't want
+ System.out.println("searchForReviews");
+ Response searchForReviews = UtilIT.search("itemReviewedUrl:\"" + itemReviewedUrl + "\"", null);
+ searchForReviews.prettyPrint();
+ searchForReviews.then().assertThat()
+ .statusCode(OK.getStatusCode())
+ .body("data.items[0].name", is(reviewTitle));
+
+ System.out.println("getReviews");
+ Response getReviews = UtilIT.getReviews(datasetPid);
+ getReviews.prettyPrint();
+ getReviews.then().assertThat()
+ .statusCode(OK.getStatusCode())
+ .body("data.reviews[0].title", is(reviewTitle))
+ .body("data.reviews[0].persistentId", is(reviewPid))
+ .body("data.reviews[0].id", is(reviewId));
+
+ System.out.println("exportDatasetHasReviews");
+ Response exportDatasetHasReviews = UtilIT.exportDataset(datasetPid, "croissant");
+ exportDatasetHasReviews.prettyPrint();
+ String noteBias = "reviews[0].positiveNotes.itemListElement.find { it.name == 'biasEquityAndRepresentativeness' }";
+ exportDatasetHasReviews.then().assertThat()
+ .statusCode(OK.getStatusCode())
+ .body("reviews[0].@context", is("https://schema.org/"))
+ .body("reviews[0].@type", is("CriticReview"))
+ .body("reviews[0].itemReviewed.@type", is("Dataset"))
+ .body("reviews[0].itemReviewed.name", is("Review of Pediatric Asthma"))
+ // .body("reviews[0].author.@type", is("Person"))
+ .body("reviews[0].creator[0].name", is("Wazowski, Mike"))
+ .body("reviews[0].positiveNotes.@type", is("ItemList"))
+ .body(noteBias + ".@type", is("StructuredValue"))
+ .body(noteBias + ".value.@type", is("QualitativeValue"))
+ .body(noteBias + ".value.value", is("Low"))
+ .body("reviews[0].reviewBody", is("This is a review of a dataset."))
+ // starting with 2 for 2026, for example
+ .body("reviews[0].datePublished", startsWith("2"))
+ .body("reviews[0].id", is(reviewId));
+ }
+
}
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 561a9bc3b93..8158d428d74 100644
--- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java
+++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java
@@ -5417,7 +5417,25 @@ public static Response getTemplate(String templateId, String apiToken) {
.header(API_TOKEN_HTTP_HEADER, apiToken)
.get("/api/dataverses/" + templateId + "/template");
}
-
+
+ static Response getReviews(String datasetIdOrPersistentId) {
+ return getReviews(datasetIdOrPersistentId, null);
+ }
+
+ static Response getReviews(String datasetIdOrPersistentId, 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.
+ if (!NumberUtils.isCreatable(datasetIdOrPersistentId)) {
+ idInPath = ":persistentId";
+ optionalQueryParam = "?persistentId=" + datasetIdOrPersistentId;
+ }
+ RequestSpecification responseSpec = given();
+ if (apiToken != null) {
+ responseSpec.header(API_TOKEN_HTTP_HEADER, apiToken);
+ }
+ return responseSpec.get("/api/datasets/" + idInPath + "/reviews" + optionalQueryParam);
+ }
+
/**
* Gets the tool URL for a dataset with optional parameters
* @param datasetId The ID of the dataset
diff --git a/src/test/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtilTest.java b/src/test/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtilTest.java
new file mode 100644
index 00000000000..aa544f7a930
--- /dev/null
+++ b/src/test/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtilTest.java
@@ -0,0 +1,48 @@
+package edu.harvard.iq.dataverse.export.croissant;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import edu.harvard.iq.dataverse.util.json.JsonUtil;
+import jakarta.json.Json;
+import jakarta.json.JsonObject;
+import jakarta.json.JsonObjectBuilder;
+import jakarta.json.JsonWriter;
+import jakarta.json.JsonWriterFactory;
+import jakarta.json.stream.JsonGenerator;
+
+public class CroissantExportUtilTest {
+
+ @Test
+ void testGetReviews() throws IOException {
+ String content = Files.readString(Path.of("doc/sphinx-guides/source/_static/api/list-reviews.json"), StandardCharsets.UTF_8);
+ JsonObject apiResponseJson = JsonUtil.getJsonObject(content);
+ JsonObjectBuilder job = Json.createObjectBuilder(apiResponseJson.getJsonObject("data"));
+ JsonObject result = CroissantExportUtil.getReviews(job).build();
+ System.out.println(prettyPrint(result));
+ assertTrue(result.getJsonArray("reviews").size() == 1);
+ assertEquals("CriticReview", result.getJsonArray("reviews").get(0).asJsonObject().getString("@type"));
+ }
+
+ public static String prettyPrint(JsonObject jsonObject) {
+ Map config = new HashMap<>();
+ config.put(JsonGenerator.PRETTY_PRINTING, true);
+ JsonWriterFactory jsonWriterFactory = Json.createWriterFactory(config);
+ StringWriter stringWriter = new StringWriter();
+ try (JsonWriter jsonWriter = jsonWriterFactory.createWriter(stringWriter)) {
+ jsonWriter.writeObject(jsonObject);
+ }
+ return stringWriter.toString();
+ }
+
+}