diff --git a/doc/release-notes/9317-delete-saved-search.md b/doc/release-notes/9317-delete-saved-search.md new file mode 100644 index 00000000000..34723801036 --- /dev/null +++ b/doc/release-notes/9317-delete-saved-search.md @@ -0,0 +1,4 @@ +### Saved search deletion + +Saved searches can now be removed using API `/api/admin/savedsearches/$id`. See PR #10198. +This is reflected in the [Saved Search Native API section](https://dataverse-guide--10198.org.readthedocs.build/en/10198/api/native-api.html#saved-search) of the Guide. \ No newline at end of file diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index bf790c47778..b16ea55bd25 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -5852,7 +5852,8 @@ The ``$identifier`` should start with an ``@`` if it's a user. Groups start with Saved Search ~~~~~~~~~~~~ -The Saved Search, Linked Dataverses, and Linked Datasets features shipped with Dataverse 4.0, but as a "`superuser-only `_" because they are **experimental** (see `#1364 `_, `#1813 `_, `#1840 `_, `#1890 `_, `#1939 `_, `#2167 `_, `#2186 `_, `#2053 `_, and `#2543 `_). The following API endpoints were added to help people with access to the "admin" API make use of these features in their current form. There is a known issue (`#1364 `_) that once a link to a Dataverse collection or dataset is created, it cannot be removed (apart from database manipulation and reindexing) which is why a ``DELETE`` endpoint for saved searches is neither documented nor functional. The Linked Dataverse collections feature is `powered by Saved Search `_ and therefore requires that the "makelinks" endpoint be executed on a periodic basis as well. +The Saved Search, Linked Dataverses, and Linked Datasets features are only accessible to superusers except for linking a dataset. The following API endpoints were added to help people with access to the "admin" API make use of these features in their current form. Keep in mind that they are partially experimental. +The update of all saved search is run by a timer once a week (See :ref:`saved-search-timer`) so if you just created a saved search, you can run manually ``makelinks`` endpoint that will find new dataverses and datasets that match the saved search and then link the search results to the dataverse in which the saved search is defined. List all saved searches. :: @@ -5862,6 +5863,12 @@ List a saved search by database id. :: GET http://$SERVER/api/admin/savedsearches/$id +Delete a saved search by database id. + +The ``unlink=true`` query parameter unlinks all links (linked dataset or Dataverse collection) associated with the deleted saved search. Use of this parameter should be well considered as you cannot know if the links were created manually or by the saved search. After deleting a saved search with ``unlink=true``, we recommend running ``/makelinks/all`` just in case there was a dataset that was linked by another saved search. (Saved searches can link the same dataset.) Reindexing might be necessary as well.:: + + DELETE http://$SERVER/api/admin/savedsearches/$id?unlink=true + Execute a saved search by database id and make links to Dataverse collections and datasets that are found. The JSON response indicates which Dataverse collections and datasets were newly linked versus already linked. The ``debug=true`` query parameter adds to the JSON response extra information about the saved search being executed (which you could also get by listing the saved search). :: PUT http://$SERVER/api/admin/savedsearches/makelinks/$id?debug=true diff --git a/src/main/java/edu/harvard/iq/dataverse/api/SavedSearches.java b/src/main/java/edu/harvard/iq/dataverse/api/SavedSearches.java index 5d0365d022e..33a11a2df23 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/SavedSearches.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/SavedSearches.java @@ -55,7 +55,7 @@ public Response list() { savedSearchesBuilder.add(thisSavedSearch); } JsonObjectBuilder response = Json.createObjectBuilder(); - response.add("saved searches", savedSearchesBuilder); + response.add("savedSearches", savedSearchesBuilder); return ok(response); } @@ -90,7 +90,6 @@ private JsonObjectBuilder toJson(SavedSearch savedSearch) { @POST public Response add(JsonObject body) { - if (body == null) { return error(BAD_REQUEST, "JSON is expected."); } @@ -159,7 +158,7 @@ public Response add(JsonObject body) { try { SavedSearch persistedSavedSearch = savedSearchSvc.add(toPersist); - return ok("Added: " + persistedSavedSearch); + return ok("Added: " + persistedSavedSearch, Json.createObjectBuilder().add("id", persistedSavedSearch.getId())); } catch (EJBException ex) { StringBuilder errors = new StringBuilder(); Throwable throwable = ex.getCause(); @@ -173,16 +172,18 @@ public Response add(JsonObject body) { @DELETE @Path("{id}") - public Response delete(@PathParam("id") long doomedId) { - boolean disabled = true; - if (disabled) { - return error(BAD_REQUEST, "Saved Searches can not safely be deleted because links can not safely be deleted. See https://github.com/IQSS/dataverse/issues/1364 for details."); - } + public Response delete(@PathParam("id") long doomedId, @QueryParam("unlink") boolean unlink) { SavedSearch doomed = savedSearchSvc.find(doomedId); if (doomed == null) { return error(NOT_FOUND, "Could not find saved search id " + doomedId); } - boolean wasDeleted = savedSearchSvc.delete(doomedId); + boolean wasDeleted; + try { + wasDeleted = savedSearchSvc.delete(doomedId, unlink); + } catch (Exception e) { + return error(INTERNAL_SERVER_ERROR, "Problem while trying to unlink links of saved search id " + doomedId); + } + if (wasDeleted) { return ok(Json.createObjectBuilder().add("Deleted", doomedId)); } else { diff --git a/src/main/java/edu/harvard/iq/dataverse/search/savedsearch/SavedSearchServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/search/savedsearch/SavedSearchServiceBean.java index 7fc2bdf79a3..1dd89f75a26 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/savedsearch/SavedSearchServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/savedsearch/SavedSearchServiceBean.java @@ -2,29 +2,28 @@ import edu.harvard.iq.dataverse.Dataset; import edu.harvard.iq.dataverse.DatasetLinkingDataverse; +import edu.harvard.iq.dataverse.DatasetLinkingServiceBean; import edu.harvard.iq.dataverse.Dataverse; import edu.harvard.iq.dataverse.DataverseLinkingDataverse; +import edu.harvard.iq.dataverse.DataverseLinkingServiceBean; import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.DvObjectServiceBean; import edu.harvard.iq.dataverse.EjbDataverseEngine; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.GuestUser; import edu.harvard.iq.dataverse.engine.command.DataverseRequest; -import edu.harvard.iq.dataverse.search.SearchServiceBean; -import edu.harvard.iq.dataverse.search.SolrQueryResponse; -import edu.harvard.iq.dataverse.search.SolrSearchResult; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.impl.DeleteDatasetLinkingDataverseCommand; +import edu.harvard.iq.dataverse.engine.command.impl.DeleteDataverseLinkingDataverseCommand; import edu.harvard.iq.dataverse.engine.command.impl.LinkDatasetCommand; import edu.harvard.iq.dataverse.engine.command.impl.LinkDataverseCommand; import edu.harvard.iq.dataverse.search.SearchException; import edu.harvard.iq.dataverse.search.SearchFields; +import edu.harvard.iq.dataverse.search.SearchServiceBean; +import edu.harvard.iq.dataverse.search.SolrQueryResponse; +import edu.harvard.iq.dataverse.search.SolrSearchResult; import edu.harvard.iq.dataverse.search.SortBy; import edu.harvard.iq.dataverse.util.SystemConfig; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.logging.Level; -import java.util.logging.Logger; import jakarta.ejb.EJB; import jakarta.ejb.Schedule; import jakarta.ejb.Stateless; @@ -39,6 +38,12 @@ import jakarta.persistence.TypedQuery; import jakarta.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + @Stateless @Named public class SavedSearchServiceBean { @@ -50,6 +55,10 @@ public class SavedSearchServiceBean { @EJB DvObjectServiceBean dvObjectService; @EJB + protected DatasetLinkingServiceBean dsLinkingService; + @EJB + protected DataverseLinkingServiceBean dvLinkingService; + @EJB EjbDataverseEngine commandEngine; @EJB SystemConfig systemConfig; @@ -96,21 +105,25 @@ public SavedSearch add(SavedSearch toPersist) { try { persisted = em.merge(toPersist); } catch (Exception ex) { - System.out.println("exeption: " + ex); + logger.fine("Failed to add SavedSearch" + ex); } return persisted; } - public boolean delete(long id) { + public boolean delete(long id, boolean unlink) throws SearchException, CommandException { SavedSearch doomed = find(id); boolean wasDeleted = false; if (doomed != null) { - System.out.println("deleting saved search id " + doomed.getId()); + logger.info("Deleting saved search id " + doomed.getId()); + if(unlink) { + DataverseRequest dataverseRequest = new DataverseRequest(doomed.getCreator(), getHttpServletRequest()); + removeLinks(dataverseRequest, doomed); + } em.remove(doomed); em.flush(); wasDeleted = true; } else { - System.out.println("problem deleting saved search id " + id); + logger.info("Problem deleting saved search id " + id); } return wasDeleted; } @@ -240,6 +253,45 @@ public JsonObjectBuilder makeLinksForSingleSavedSearch(DataverseRequest dvReq, S return response; } + /** + * This method to the reverse of a makeLinksForSingleSavedSearch method. + * It removes all Dataset and Dataverse links that match savedSearch's query. + * @param dvReq + * @param savedSearch + * @throws SearchException + * @throws CommandException + */ + public void removeLinks(DataverseRequest dvReq, SavedSearch savedSearch) throws SearchException, CommandException { + logger.fine("UNLINK SAVED SEARCH (" + savedSearch.getId() + ") START search and unlink process"); + Date start = new Date(); + Dataverse linkingDataverse = savedSearch.getDefinitionPoint(); + + SolrQueryResponse queryResponse = findHits(savedSearch); + for (SolrSearchResult solrSearchResult : queryResponse.getSolrSearchResults()) { + + DvObject dvObjectThatDefinitionPointWillLinkTo = dvObjectService.findDvObject(solrSearchResult.getEntityId()); + if (dvObjectThatDefinitionPointWillLinkTo == null) { + continue; + } + + if (dvObjectThatDefinitionPointWillLinkTo.isInstanceofDataverse()) { + Dataverse linkedDataverse = (Dataverse) dvObjectThatDefinitionPointWillLinkTo; + DataverseLinkingDataverse dvld = dvLinkingService.findDataverseLinkingDataverse(linkedDataverse.getId(), linkingDataverse.getId()); + if(dvld != null) { + Dataverse dv = commandEngine.submitInNewTransaction(new DeleteDataverseLinkingDataverseCommand(dvReq, linkingDataverse, dvld, true)); + } + } else if (dvObjectThatDefinitionPointWillLinkTo.isInstanceofDataset()) { + Dataset linkedDataset = (Dataset) dvObjectThatDefinitionPointWillLinkTo; + DatasetLinkingDataverse dsld = dsLinkingService.findDatasetLinkingDataverse(linkedDataset.getId(), linkingDataverse.getId()); + if(dsld != null) { + Dataset ds = commandEngine.submitInNewTransaction(new DeleteDatasetLinkingDataverseCommand(dvReq, linkedDataset, dsld, true)); + } + } + } + + logger.fine("UNLINK SAVED SEARCH (" + savedSearch.getId() + ") total time in ms: " + (new Date().getTime() - start.getTime())); + } + private SolrQueryResponse findHits(SavedSearch savedSearch) throws SearchException { String sortField = SearchFields.TYPE; // first return dataverses, then datasets String sortOrder = SortBy.DESCENDING; diff --git a/src/test/java/edu/harvard/iq/dataverse/api/SavedSearchIT.java b/src/test/java/edu/harvard/iq/dataverse/api/SavedSearchIT.java new file mode 100644 index 00000000000..90357596c25 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/SavedSearchIT.java @@ -0,0 +1,200 @@ +package edu.harvard.iq.dataverse.api; + +import io.restassured.RestAssured; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import jakarta.json.Json; +import jakarta.json.JsonArrayBuilder; +import jakarta.json.JsonObjectBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; +import static jakarta.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; +import static jakarta.ws.rs.core.Response.Status.NOT_FOUND; +import static jakarta.ws.rs.core.Response.Status.OK; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class SavedSearchIT { + + @BeforeAll + public static void setUpClass() { + + } + + @AfterAll + public static void afterClass() { + + } + + @Test + public void testSavedSearches() { + + Response createAdminUser = UtilIT.createRandomUser(); + String adminUsername = UtilIT.getUsernameFromResponse(createAdminUser); + String adminApiToken = UtilIT.getApiTokenFromResponse(createAdminUser); + UtilIT.makeSuperUser(adminUsername); + + Response createDataverseResponse = UtilIT.createRandomDataverse(adminApiToken); + createDataverseResponse.prettyPrint(); + String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + Integer dataverseId = UtilIT.getDataverseIdFromResponse(createDataverseResponse); + + //dataset-finch1-nolicense.json + Response createDatasetResponse1 = UtilIT.createRandomDatasetViaNativeApi(dataverseAlias, adminApiToken); + createDatasetResponse1.prettyPrint(); + Integer datasetId = UtilIT.getDatasetIdFromResponse(createDatasetResponse1); + + Response createDatasetResponse2 = UtilIT.createRandomDatasetViaNativeApi(dataverseAlias, adminApiToken); + createDatasetResponse2.prettyPrint(); + Integer datasetId2 = UtilIT.getDatasetIdFromResponse(createDatasetResponse2); + + // missing body + Response resp = RestAssured.given() + .contentType("application/json") + .post("/api/admin/savedsearches"); + resp.prettyPrint(); + resp.then().assertThat() + .statusCode(INTERNAL_SERVER_ERROR.getStatusCode()); + + // creatorId null + resp = RestAssured.given() + .body(createSavedSearchJson("*", null, dataverseId, "subject_ss:Medicine, Health and Life Sciences")) + .contentType("application/json") + .post("/api/admin/savedsearches"); + resp.prettyPrint(); + resp.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()); + + // creatorId string + resp = RestAssured.given() + .body(createSavedSearchJson("*", "1", dataverseId.toString(), "subject_ss:Medicine, Health and Life Sciences")) + .contentType("application/json") + .post("/api/admin/savedsearches"); + resp.prettyPrint(); + resp.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()); + + // creatorId not found + resp = RestAssured.given() + .body(createSavedSearchJson("*", 9999, dataverseId, "subject_ss:Medicine, Health and Life Sciences")) + .contentType("application/json") + .post("/api/admin/savedsearches"); + resp.prettyPrint(); + resp.then().assertThat() + .statusCode(NOT_FOUND.getStatusCode()); + + // definitionPointId null + resp = RestAssured.given() + .body(createSavedSearchJson("*", 1, null, "subject_ss:Medicine, Health and Life Sciences")) + .contentType("application/json") + .post("/api/admin/savedsearches"); + resp.prettyPrint(); + resp.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()); + + // definitionPointId string + resp = RestAssured.given() + .body(createSavedSearchJson("*", "1", "9999", "subject_ss:Medicine, Health and Life Sciences")) + .contentType("application/json") + .post("/api/admin/savedsearches"); + resp.prettyPrint(); + resp.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()); + + // definitionPointId not found + resp = RestAssured.given() + .body(createSavedSearchJson("*", 1, 9999, "subject_ss:Medicine, Health and Life Sciences")) + .contentType("application/json") + .post("/api/admin/savedsearches"); + resp.prettyPrint(); + resp.then().assertThat() + .statusCode(NOT_FOUND.getStatusCode()); + + // missing filter + resp = RestAssured.given() + .body(createSavedSearchJson("*", 1, dataverseId)) + .contentType("application/json") + .post("/api/admin/savedsearches"); + resp.prettyPrint(); + resp.then().assertThat() + .statusCode(OK.getStatusCode()); + + // create a saved search as superuser : OK + resp = RestAssured.given() + .body(createSavedSearchJson("*", 1, dataverseId, "subject_ss:Medicine, Health and Life Sciences")) + .contentType("application/json") + .post("/api/admin/savedsearches"); + resp.prettyPrint(); + resp.then().assertThat() + .statusCode(OK.getStatusCode()); + + JsonPath path = JsonPath.from(resp.body().asString()); + Integer createdSavedSearchId = path.getInt("data.id"); + + // get list as non superuser : OK + Response getListReponse = RestAssured.given() + .get("/api/admin/savedsearches/list"); + getListReponse.prettyPrint(); + getListReponse.then().assertThat() + .statusCode(OK.getStatusCode()); + + JsonPath path2 = JsonPath.from(getListReponse.body().asString()); + List listBeforeDelete = path2.getList("data.savedSearches"); + + // makelinks/all as non superuser : OK + Response makelinksAll = RestAssured.given() + .put("/api/admin/savedsearches/makelinks/all"); + makelinksAll.prettyPrint(); + makelinksAll.then().assertThat() + .statusCode(OK.getStatusCode()); + + //delete a saved search as non superuser : OK + Response deleteReponse = RestAssured.given() + .delete("/api/admin/savedsearches/" + createdSavedSearchId); + deleteReponse.prettyPrint(); + deleteReponse.then().assertThat() + .statusCode(OK.getStatusCode()); + + // check list count minus 1 + getListReponse = RestAssured.given() + .get("/api/admin/savedsearches/list"); + getListReponse.prettyPrint(); + JsonPath path3 = JsonPath.from(getListReponse.body().asString()); + List listAfterDelete = path3.getList("data.savedSearches"); + assertEquals(listBeforeDelete.size() - 1, listAfterDelete.size()); + } + + public String createSavedSearchJson(String query, Integer creatorId, Integer definitionPointId, String... filterQueries) { + + JsonArrayBuilder arr = Json.createArrayBuilder(); + for (String filterQuery : filterQueries) { + arr.add(filterQuery); + } + + JsonObjectBuilder json = Json.createObjectBuilder(); + if(query != null) json.add("query", query); + if(creatorId != null) json.add("creatorId", creatorId); + if(definitionPointId != null) json.add("definitionPointId", definitionPointId); + if(filterQueries.length > 0) json.add("filterQueries", arr); + return json.build().toString(); + } + + public String createSavedSearchJson(String query, String creatorId, String definitionPointId, String... filterQueries) { + + JsonArrayBuilder arr = Json.createArrayBuilder(); + for (String filterQuery : filterQueries) { + arr.add(filterQuery); + } + + JsonObjectBuilder json = Json.createObjectBuilder(); + if(query != null) json.add("query", query); + if(creatorId != null) json.add("creatorId", creatorId); + if(definitionPointId != null) json.add("definitionPointId", definitionPointId); + if(filterQueries.length > 0) json.add("filterQueries", arr); + return json.build().toString(); + } +} diff --git a/tests/integration-tests.txt b/tests/integration-tests.txt index 64610d07e50..fc3fc9b4a3f 100644 --- a/tests/integration-tests.txt +++ b/tests/integration-tests.txt @@ -1 +1 @@ -DataversesIT,DatasetsIT,SwordIT,AdminIT,BuiltinUsersIT,UsersIT,UtilIT,ConfirmEmailIT,FileMetadataIT,FilesIT,SearchIT,InReviewWorkflowIT,HarvestingServerIT,HarvestingClientsIT,MoveIT,MakeDataCountApiIT,FileTypeDetectionIT,EditDDIIT,ExternalToolsIT,AccessIT,DuplicateFilesIT,DownloadFilesIT,LinkIT,DeleteUsersIT,DeactivateUsersIT,AuxiliaryFilesIT,InvalidCharactersIT,LicensesIT,NotificationsIT,BagIT,MetadataBlocksIT,NetcdfIT,SignpostingIT,FitsIT,LogoutIT,DataRetrieverApiIT,ProvIT,S3AccessIT,OpenApiIT,InfoIT,DatasetFieldsIT +DataversesIT,DatasetsIT,SwordIT,AdminIT,BuiltinUsersIT,UsersIT,UtilIT,ConfirmEmailIT,FileMetadataIT,FilesIT,SearchIT,InReviewWorkflowIT,HarvestingServerIT,HarvestingClientsIT,MoveIT,MakeDataCountApiIT,FileTypeDetectionIT,EditDDIIT,ExternalToolsIT,AccessIT,DuplicateFilesIT,DownloadFilesIT,LinkIT,DeleteUsersIT,DeactivateUsersIT,AuxiliaryFilesIT,InvalidCharactersIT,LicensesIT,NotificationsIT,BagIT,MetadataBlocksIT,NetcdfIT,SignpostingIT,FitsIT,LogoutIT,DataRetrieverApiIT,ProvIT,S3AccessIT,OpenApiIT,InfoIT,DatasetFieldsIT,SavedSearchIT