diff --git a/doc/release-notes/12313-local-reviews.md b/doc/release-notes/12313-local-reviews.md new file mode 100644 index 00000000000..4f28dba0924 --- /dev/null +++ b/doc/release-notes/12313-local-reviews.md @@ -0,0 +1,5 @@ +### Local Reviews + +Datasets can have reviews. Specifically, if a 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. + +See [the guides](https://dataverse-guide--12327.org.readthedocs.build/en/12327/api/native-api.html#list-reviews), #12313 and #12327. diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index d8da7b8df26..a4dc852d9e8 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -4752,6 +4752,29 @@ 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. + +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" + Files ----- diff --git a/doc/sphinx-guides/source/user/dataset-management.rst b/doc/sphinx-guides/source/user/dataset-management.rst index 9c389ef4be3..faa03962440 100755 --- a/doc/sphinx-guides/source/user/dataset-management.rst +++ b/doc/sphinx-guides/source/user/dataset-management.rst @@ -935,6 +935,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. + .. _dataset-types-datacite: Dataset Types and DataCite 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..9a4cc06b0cf 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -6261,7 +6261,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..7e1a9e51fa6 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetReviewsCommand.java @@ -0,0 +1,94 @@ +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.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.JsonArrayBuilder; +import jakarta.json.JsonObject; +import jakarta.json.JsonObjectBuilder; + +// 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. + JsonObjectBuilder searchResultBuilder = solrSearchResult.json(false, true, false); + JsonObject searchResultObject = searchResultBuilder.build(); + String title = searchResultObject.getString("name"); + 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(); + JsonObjectBuilder review = Json.createObjectBuilder() + .add("title", title) + .add("persistentId", pid) + .add("persistentIdUrl", pidUrl) + .add("id", id) + .add("citation", citation) + .add("citationHtml", citationHtml); + 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/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java index 2fc74c4b3f1..a084cf993f8 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; @@ -17,6 +20,7 @@ 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; @@ -344,4 +348,248 @@ 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 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.")); + + 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")))))); + + 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].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 + Response searchForReviews = UtilIT.search("itemReviewedUrl:\"" + itemReviewedUrl + "\"", null); + searchForReviews.prettyPrint(); + searchForReviews.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.items[0].name", is(reviewTitle)); + + 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)); + } + } 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 8333f502e06..bfce44c5eba 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -5350,7 +5350,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