diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3fed0e36e8a8..5554d6a37e5d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,14 +81,14 @@ jobs: # (This artifact is downloadable at the bottom of any job's summary page) - name: Upload Results of ${{ matrix.type }} to Artifact if: ${{ failure() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ matrix.type }} results path: ${{ matrix.resultsdir }} # Upload code coverage report to artifact, so that it can be shared with the 'codecov' job (see below) - name: Upload code coverage report to Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ matrix.type }} coverage report path: 'dspace/target/site/jacoco-aggregate/jacoco.xml' diff --git a/dspace-api/src/main/java/org/dspace/app/bulkaccesscontrol/BulkAccessControl.java b/dspace-api/src/main/java/org/dspace/app/bulkaccesscontrol/BulkAccessControl.java index 7bef232f0450..bfb8b24f33e2 100644 --- a/dspace-api/src/main/java/org/dspace/app/bulkaccesscontrol/BulkAccessControl.java +++ b/dspace-api/src/main/java/org/dspace/app/bulkaccesscontrol/BulkAccessControl.java @@ -55,6 +55,7 @@ import org.dspace.content.service.ItemService; import org.dspace.core.Constants; import org.dspace.core.Context; +import org.dspace.core.ProvenanceService; import org.dspace.discovery.DiscoverQuery; import org.dspace.discovery.SearchService; import org.dspace.discovery.SearchServiceException; @@ -111,6 +112,8 @@ public class BulkAccessControl extends DSpaceRunnable { + ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + private static final Logger log = LogManager.getLogger(HealthReport.class); + private EPersonService ePersonService; + + /** + * Checks to be performed. + */ + private static final LinkedHashMap checks = Report.checks(); + + /** + * `-i`: Info, show help information. + */ + private boolean info = false; + + /** + * `-e`: Email, send report to specified email address. + */ + private String[] emails; + + /** + * `-c`: Check, perform only specific check by index (0-`getNumberOfChecks()`). + */ + private int specificCheck = -1; + + /** + * `-f`: For, specify the last N days to consider. + * Default value is set in dspace.cfg. + */ + private int forLastNDays = configurationService.getIntProperty("healthcheck.last_n_days"); + + /** + * `-o`: Output, specify a file to save the report. + */ + private String fileName; + + @Override + public HealthReportScriptConfiguration getScriptConfiguration() { + return new DSpace().getServiceManager() + .getServiceByName("health-report", HealthReportScriptConfiguration.class); + } + + @Override + public void setup() throws ParseException { + ePersonService = EPersonServiceFactory.getInstance().getEPersonService(); + // `-i`: Info, show help information. + if (commandLine.hasOption('i')) { + info = true; + return; + } + + // `-e`: Email, send report to specified email address. + if (commandLine.hasOption('e')) { + emails = commandLine.getOptionValues('e'); + handler.logInfo("\nReport sent to this email address: " + String.join(", ", emails)); + } + + // `-c`: Check, perform only specific check by index (0-`getNumberOfChecks()`). + if (commandLine.hasOption('c')) { + String checkOption = commandLine.getOptionValue('c'); + try { + specificCheck = Integer.parseInt(checkOption); + if (specificCheck < 0 || specificCheck >= getNumberOfChecks()) { + specificCheck = -1; + } + } catch (NumberFormatException e) { + log.info("Invalid value for check. It has to be a number from the displayed range."); + return; + } + } + + // `-f`: For, specify the last N days to consider. + if (commandLine.hasOption('f')) { + String daysOption = commandLine.getOptionValue('f'); + try { + forLastNDays = Integer.parseInt(daysOption); + } catch (NumberFormatException e) { + log.info("Invalid value for last N days. Argument f has to be a number."); + return; + } + } + + // `-o`: Output, specify a file to save the report. + if (commandLine.hasOption('o')) { + fileName = commandLine.getOptionValue('o'); + } + } + + @Override + public void internalRun() throws Exception { + if (info) { + printHelp(); + return; + } + + ReportInfo ri = new ReportInfo(this.forLastNDays); + + StringBuilder sbReport = new StringBuilder(); + sbReport.append("\n\nHEALTH REPORT:\n"); + + int position = -1; + for (Map.Entry check_entry : Report.checks().entrySet()) { + ++position; + if (specificCheck != -1 && specificCheck != position) { + continue; + } + + String name = check_entry.getKey(); + Check check = check_entry.getValue(); + + log.info("#{}. Processing [{}] at [{}]", position, name, new SimpleDateFormat( + "yyyy-MM-dd HH:mm:ss.SSS").format(new Date())); + + sbReport.append("\n######################\n\n").append(name).append(":\n"); + check.report(ri); + sbReport.append(check.getReport()); + } + + // save output to file + if (fileName != null) { + Context context = new Context(); + context.setCurrentUser(ePersonService.find(context, this.getEpersonIdentifier())); + + InputStream inputStream = toInputStream(sbReport.toString(), StandardCharsets.UTF_8); + handler.writeFilestream(context, fileName, inputStream, "export"); + + context.restoreAuthSystemState(); + context.complete(); + } + + // send email to email address from argument + if (emails != null && emails.length > 0) { + try { + Email e = Email.getEmail(I18nUtil.getEmailFilename(Locale.getDefault(), "healthcheck")); + for (String recipient : emails) { + e.addRecipient(recipient); + } + e.addArgument(sbReport.toString()); + e.send(); + } catch (IOException | MessagingException e) { + log.error("Error sending email:", e); + } + } + + handler.logInfo(sbReport.toString()); + } + + @Override + public void printHelp() { + handler.logInfo("\n\nINFORMATION\nThis process creates a health report of your DSpace.\n" + + "You can choose from these available options:\n" + + " -i, --info Show help information\n" + + " -e, --email Send report to specified email address\n" + + " -c, --check Perform only specific check by index (0-" + (getNumberOfChecks() - 1) + ")\n" + + " -f, --for Specify the last N days to consider\n" + + " -o, --output Specify a file to save the report\n\n" + + "If you want to execute only one check using -c, use check index:\n" + checksNamesToString() + "\n" + ); + } + + /** + * Convert checks names to string. + */ + private String checksNamesToString() { + StringBuilder names = new StringBuilder(); + int pos = 0; + for (String name : checks.keySet()) { + names.append(String.format(" %d. %s\n", pos++, name)); + } + return names.toString(); + } + + /** + * Get the number of checks. This is used for the `-c` option. + */ + public static int getNumberOfChecks() { + return checks.size(); + } +} diff --git a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java new file mode 100644 index 000000000000..771cc70aadb9 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java @@ -0,0 +1,54 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.healthreport; + +import org.apache.commons.cli.Options; +import org.dspace.scripts.configuration.ScriptConfiguration; + +/** + * This class represents a HealthReport that is used in the CLI. + * @author Matus Kasak (dspace at dataquest.sk) + */ +public class HealthReportScriptConfiguration extends ScriptConfiguration { + + private Class dspaceRunnableclass; + + @Override + public Class getDspaceRunnableClass() { + return dspaceRunnableclass; + } + + @Override + public void setDspaceRunnableClass(Class dspaceRunnableClass) { + this.dspaceRunnableclass = dspaceRunnableClass; + } + + @Override + public Options getOptions() { + if (options == null) { + Options options = new Options(); + options.addOption("i", "info", false, + "Show help information."); + options.addOption("e", "email", true, + "Send report to this email address."); + options.getOption("e").setType(String.class); + options.addOption("c", "check", true, + String.format("Perform only specific check (use index from 0 to %d, " + + "otherwise perform default checks).", HealthReport.getNumberOfChecks() - 1)); + options.getOption("c").setType(String.class); + options.addOption("f", "for", true, + "Report for last N days. Used only in general information for now."); + options.getOption("f").setType(String.class); + options.addOption("o", "output", true, + "Save report to the file."); + + super.options = options; + } + return options; + } +} diff --git a/dspace-api/src/main/java/org/dspace/authorize/ResourcePolicyServiceImpl.java b/dspace-api/src/main/java/org/dspace/authorize/ResourcePolicyServiceImpl.java index b762107a84c5..b32a1b89465f 100644 --- a/dspace-api/src/main/java/org/dspace/authorize/ResourcePolicyServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/authorize/ResourcePolicyServiceImpl.java @@ -24,6 +24,7 @@ import org.dspace.content.factory.ContentServiceFactory; import org.dspace.core.Constants; import org.dspace.core.Context; +import org.dspace.core.ProvenanceService; import org.dspace.eperson.EPerson; import org.dspace.eperson.Group; import org.dspace.eperson.service.GroupService; @@ -51,6 +52,12 @@ public class ResourcePolicyServiceImpl implements ResourcePolicyService { @Autowired private GroupService groupService; + @Autowired + ProvenanceService provenanceService; + + @Autowired + ResourcePolicyService resourcePolicyService; + protected ResourcePolicyServiceImpl() { } @@ -233,12 +240,17 @@ public void removePolicies(Context c, DSpaceObject o, String type) throws SQLExc } @Override - public void removePolicies(Context c, DSpaceObject o, String type, int action) - throws SQLException, AuthorizeException { + public void removePolicies(Context c, DSpaceObject o, String type, int action) + throws SQLException, AuthorizeException { + // Get all read policies of the dso before removing them + List resPolicies = resourcePolicyService.find(c, o, type); + resourcePolicyDAO.deleteByDsoAndTypeAndAction(c, o, type, action); c.turnOffAuthorisationSystem(); contentServiceFactory.getDSpaceObjectService(o).updateLastModified(c, o); c.restoreAuthSystemState(); + + provenanceService.removeReadPolicies(c, o, resPolicies); } @Override diff --git a/dspace-api/src/main/java/org/dspace/content/DSpaceObjectServiceImpl.java b/dspace-api/src/main/java/org/dspace/content/DSpaceObjectServiceImpl.java index 2119959073f0..a3d2316d0d05 100644 --- a/dspace-api/src/main/java/org/dspace/content/DSpaceObjectServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/DSpaceObjectServiceImpl.java @@ -34,6 +34,7 @@ import org.dspace.content.service.RelationshipService; import org.dspace.core.Constants; import org.dspace.core.Context; +import org.dspace.core.ProvenanceService; import org.dspace.handle.service.HandleService; import org.dspace.identifier.service.IdentifierService; import org.dspace.utils.DSpace; @@ -67,6 +68,8 @@ public abstract class DSpaceObjectServiceImpl implements protected MetadataAuthorityService metadataAuthorityService; @Autowired(required = true) protected RelationshipService relationshipService; + @Autowired(required = true) + protected ProvenanceService provenanceService; public DSpaceObjectServiceImpl() { @@ -377,6 +380,7 @@ public void clearMetadata(Context context, T dso, String schema, String element, } } dso.setMetadataModified(); + provenanceService.removeMetadata(context, dso, schema, element, qualifier); } @Override diff --git a/dspace-api/src/main/java/org/dspace/content/InstallItemServiceImpl.java b/dspace-api/src/main/java/org/dspace/content/InstallItemServiceImpl.java index f622b98d5ea9..aee5fc74ec0e 100644 --- a/dspace-api/src/main/java/org/dspace/content/InstallItemServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/InstallItemServiceImpl.java @@ -9,13 +9,20 @@ import java.io.IOException; import java.sql.SQLException; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.dspace.authorize.AuthorizeException; +import org.dspace.authorize.ResourcePolicy; +import org.dspace.authorize.service.ResourcePolicyService; import org.dspace.content.factory.ContentServiceFactory; import org.dspace.content.logic.Filter; import org.dspace.content.logic.FilterUtils; @@ -26,10 +33,12 @@ import org.dspace.core.Context; import org.dspace.discovery.IsoLangCodes; import org.dspace.embargo.service.EmbargoService; +import org.dspace.eperson.EPerson; import org.dspace.event.Event; import org.dspace.identifier.Identifier; import org.dspace.identifier.IdentifierException; import org.dspace.identifier.service.IdentifierService; +import org.dspace.services.ConfigurationService; import org.dspace.supervision.SupervisionOrder; import org.dspace.supervision.service.SupervisionOrderService; import org.springframework.beans.factory.annotation.Autowired; @@ -57,6 +66,9 @@ public class InstallItemServiceImpl implements InstallItemService { @Autowired(required = true) protected SupervisionOrderService supervisionOrderService; @Autowired(required = false) + private ResourcePolicyService resourcePolicyService; + @Autowired(required = true) + protected ConfigurationService configurationService; Logger log = LogManager.getLogger(InstallItemServiceImpl.class); @@ -106,6 +118,12 @@ public Item installItem(Context c, InProgressSubmission is, // the default policies from the collection. itemService.inheritCollectionDefaultPolicies(c, item, collection, false); + //Allow submitter to edit item + if (isCollectionAllowedForSubmitterEditing(item.getOwningCollection()) && + isInSubmitGroup(c.getCurrentUser(), item.getOwningCollection().getID())) { + createResourcePolicy(c, item, Constants.WRITE); + } + return item; } @@ -337,4 +355,66 @@ private void addLanguageNameToMetadata(Context c, Item item) throws SQLException itemService.addMetadata(c, item, "local", "language", "name", null, langName); } } + + /** + * Checks if the provided collection is allowed for submitter metadata editing. + * + * This method retrieves a list of allowed collection names and IDs from the system configuration, + * and checks if the given collection's name or ID matches any of the allowed values. + * + * @param collection The collection to be checked. + * @return True if the collection's name or ID is in the allowed list for submitter editing, false otherwise. + * @throws SQLException If there is an issue retrieving the configuration or querying the database. + */ + private boolean isCollectionAllowedForSubmitterEditing(Collection collection) throws SQLException { + if (Objects.isNull(collection)) { + return false; + } + // Retrieve the allowed collections for submitter edition as an array + String[] editableCollections = configurationService.getArrayProperty("allow.edit.metadata", new String[] {}); + + if (Objects.isNull(editableCollections) || editableCollections.length == 0) { + return false; + } + + Set allowedNamesOrIds = new HashSet<>(Arrays.asList(editableCollections)); + + // Check if the provided collection's name or ID is in the allowed set + return allowedNamesOrIds.contains(collection.getName()) || + allowedNamesOrIds.contains(collection.getID().toString()); + } + + /** + * Checks if the given EPerson is in a submitter of the collection. + * A submit group is identified by the name containing "SUBMIT" and the collection UUID. + * + * @param eperson the EPerson whose is checked + * @param collectionUUID the UUID of the collection to check group names + * @return true if the EPerson is in a submitter, false otherwise + */ + private boolean isInSubmitGroup(EPerson eperson, UUID collectionUUID) { + return eperson.getGroups().stream() + .anyMatch(group -> group.getName().contains("SUBMIT") && + group.getName().contains(collectionUUID.toString())); + } + + /** + * Creates a resource policy for an item, granting the specified action to the current user. + * + * @param context The current DSpace context. + * @param item The item for which the resource policy is being created. + * @param action The action to be assigned to the resource policy (e.g., write, read). + * @throws SQLException If there is an issue interacting with the database. + * @throws AuthorizeException If the current user does not have sufficient authorization + * to create the resource policy. + */ + private void createResourcePolicy(Context context, Item item, int action) throws SQLException, AuthorizeException { + context.turnOffAuthorisationSystem(); + ResourcePolicy resPol = resourcePolicyService.create(context); + resPol.setAction(action); + resPol.setdSpaceObject(item); + resPol.setEPerson(item.getSubmitter()); + context.restoreAuthSystemState(); + } + } diff --git a/dspace-api/src/main/java/org/dspace/content/ItemServiceImpl.java b/dspace-api/src/main/java/org/dspace/content/ItemServiceImpl.java index e135f614ec4f..fbdd7b97dcca 100644 --- a/dspace-api/src/main/java/org/dspace/content/ItemServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/ItemServiceImpl.java @@ -53,6 +53,7 @@ import org.dspace.core.Constants; import org.dspace.core.Context; import org.dspace.core.LogHelper; +import org.dspace.core.ProvenanceService; import org.dspace.discovery.DiscoverQuery; import org.dspace.discovery.DiscoverResult; import org.dspace.discovery.SearchService; @@ -174,6 +175,10 @@ public class ItemServiceImpl extends DSpaceObjectServiceImpl implements It @Autowired(required = true) ClarinMatomoBitstreamTracker matomoBitstreamTracker; + @Autowired(required = true) + private ProvenanceService provenanceService; + + protected ItemServiceImpl() { super(); } @@ -1134,6 +1139,7 @@ public void move(Context context, Item item, Collection from, Collection to, boo context.addEvent(new Event(Event.MODIFY, Constants.ITEM, item.getID(), null, getIdentifiers(context, item))); } + provenanceService.moveItem(context, item, from); } @Override diff --git a/dspace-api/src/main/java/org/dspace/content/factory/ContentServiceFactory.java b/dspace-api/src/main/java/org/dspace/content/factory/ContentServiceFactory.java index dbe842a4194f..2e2798f49e84 100644 --- a/dspace-api/src/main/java/org/dspace/content/factory/ContentServiceFactory.java +++ b/dspace-api/src/main/java/org/dspace/content/factory/ContentServiceFactory.java @@ -34,6 +34,7 @@ import org.dspace.content.service.RelationshipTypeService; import org.dspace.content.service.SiteService; import org.dspace.content.service.WorkspaceItemService; +import org.dspace.core.ProvenanceService; import org.dspace.eperson.service.SubscribeService; import org.dspace.handle.service.HandleClarinService; import org.dspace.services.factory.DSpaceServicesFactory; @@ -77,6 +78,7 @@ public abstract class ContentServiceFactory { public abstract SiteService getSiteService(); public abstract SubscribeService getSubscribeService(); + public abstract PreviewContentService getPreviewContentService(); /** @@ -123,6 +125,13 @@ public abstract class ContentServiceFactory { */ public abstract HandleClarinService getHandleClarinService(); + /** + * Return the implementation of the ProvenanceService interface + * + * @return the ProvenanceService + */ + public abstract ProvenanceService getProvenanceService(); + public InProgressSubmissionService getInProgressSubmissionService(InProgressSubmission inProgressSubmission) { if (inProgressSubmission instanceof WorkspaceItem) { return getWorkspaceItemService(); diff --git a/dspace-api/src/main/java/org/dspace/content/factory/ContentServiceFactoryImpl.java b/dspace-api/src/main/java/org/dspace/content/factory/ContentServiceFactoryImpl.java index a38dec0c0a9d..7b340cafd0c9 100644 --- a/dspace-api/src/main/java/org/dspace/content/factory/ContentServiceFactoryImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/factory/ContentServiceFactoryImpl.java @@ -31,6 +31,7 @@ import org.dspace.content.service.RelationshipTypeService; import org.dspace.content.service.SiteService; import org.dspace.content.service.WorkspaceItemService; +import org.dspace.core.ProvenanceService; import org.dspace.eperson.service.SubscribeService; import org.dspace.handle.service.HandleClarinService; import org.springframework.beans.factory.annotation.Autowired; @@ -93,6 +94,9 @@ public class ContentServiceFactoryImpl extends ContentServiceFactory { @Autowired(required = true) private HandleClarinService handleClarinService; + @Autowired(required = true) + private ProvenanceService provenanceService; + @Override public List> getDSpaceObjectServices() { return dSpaceObjectServices; @@ -173,6 +177,11 @@ public PreviewContentService getPreviewContentService() { return previewContentService; } + @Override + public ProvenanceService getProvenanceService() { + return provenanceService; + } + @Override public RelationshipTypeService getRelationshipTypeService() { return relationshipTypeService; diff --git a/dspace-api/src/main/java/org/dspace/core/Context.java b/dspace-api/src/main/java/org/dspace/core/Context.java index 8eed24348c39..c73dd9a688e1 100644 --- a/dspace-api/src/main/java/org/dspace/core/Context.java +++ b/dspace-api/src/main/java/org/dspace/core/Context.java @@ -169,6 +169,10 @@ public Context(Mode mode) { * Initializes a new context object. */ protected void init() { + if (log.isDebugEnabled()) { + log.debug("Initializing new context with hash: {}.", getHash()); + } + updateDatabase(); if (eventService == null) { @@ -181,6 +185,11 @@ protected void init() { if (dbConnection == null) { log.fatal("Cannot obtain the bean which provides a database connection. " + "Check previous entries in the dspace.log to find why the db failed to initialize."); + } else { + if (isTransactionAlive()) { + log.warn("Initializing a context while an active transaction exists. Context with hash: {}.", + getHash()); + } } } @@ -392,6 +401,10 @@ public String getExtraLogInfo() { * or closing the connection */ public void complete() throws SQLException { + if (log.isDebugEnabled()) { + log.debug("Completing context with hash: {}.", getHash()); + } + // If Context is no longer open/valid, just note that it has already been closed if (!isValid()) { log.info("complete() was called on a closed Context object. No changes to commit."); @@ -424,6 +437,10 @@ public void complete() throws SQLException { * @throws SQLException When committing the transaction in the database fails. */ public void commit() throws SQLException { + if (log.isDebugEnabled()) { + log.debug("Committing context with hash: {}.", getHash()); + } + // If Context is no longer open/valid, just note that it has already been closed if (!isValid()) { log.info("commit() was called on a closed Context object. No changes to commit."); @@ -556,6 +573,10 @@ public Event pollEvent() { * @throws SQLException When rollbacking the transaction in the database fails. */ public void rollback() throws SQLException { + if (log.isDebugEnabled()) { + log.debug("Rolling back context with hash: {}.", getHash()); + } + // If Context is no longer open/valid, just note that it has already been closed if (!isValid()) { log.info("rollback() was called on a closed Context object. No changes to abort."); @@ -584,6 +605,10 @@ public void rollback() throws SQLException { * is a no-op. */ public void abort() { + if (log.isDebugEnabled()) { + log.debug("Aborting context with hash: {}.", getHash()); + } + // If Context is no longer open/valid, just note that it has already been closed if (!isValid()) { log.info("abort() was called on a closed Context object. No changes to abort."); @@ -617,6 +642,9 @@ public void abort() { */ @Override public void close() { + if (log.isDebugEnabled()) { + log.debug("Closing context with hash: {}.", getHash()); + } if (isValid()) { abort(); } @@ -988,4 +1016,11 @@ public String getHibernateStatistics() { } return "Hibernate statistics are not available for this database connection"; } + + /** + * Get the hash of the context object. This hash is based on the memory address of the object. + */ + public String getHash() { + return String.valueOf(System.identityHashCode(this)); + } } diff --git a/dspace-api/src/main/java/org/dspace/core/ProvenanceMessageFormatter.java b/dspace-api/src/main/java/org/dspace/core/ProvenanceMessageFormatter.java new file mode 100644 index 000000000000..a6f486b34c99 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/core/ProvenanceMessageFormatter.java @@ -0,0 +1,104 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.core; + +import java.sql.SQLException; +import java.util.List; +import java.util.stream.Collectors; + +import org.dspace.authorize.AuthorizeException; +import org.dspace.authorize.ResourcePolicy; +import org.dspace.content.Bitstream; +import org.dspace.content.Collection; +import org.dspace.content.DCDate; +import org.dspace.content.Item; +import org.dspace.content.MetadataField; +import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.InstallItemService; +import org.dspace.eperson.EPerson; + +/** + * The ProvenanceMessageProvider providing methods to generate provenance messages for DSpace items. + * It loads message templates + * from a JSON file and formats messages based on the context, including user details and timestamps. + * + * @author Michaela Paurikova (dspace at dataquest.sk) + */ +public class ProvenanceMessageFormatter { + private InstallItemService installItemService; + + public ProvenanceMessageFormatter() {} + + public String getMessage(Context context, String messageTemplate, Item item, Object... args) + throws SQLException, AuthorizeException { + // Initialize InstallItemService if it is not initialized. + if (installItemService == null) { + installItemService = ContentServiceFactory.getInstance().getInstallItemService(); + } + String msg = getMessage(context, messageTemplate, args); + msg = msg + "\n" + installItemService.getBitstreamProvenanceMessage(context, item); + return msg; + } + + public String getMessage(Context context, String messageTemplate, Object... args) { + EPerson currentUser = context.getCurrentUser(); + String timestamp = DCDate.getCurrent().toString(); + String details = validateMessageTemplate(messageTemplate, args); + return String.format("%s by %s (%s) on %s", + details, + currentUser.getFullName(), + currentUser.getEmail(), + timestamp); + } + + public String getMessage(Item item) { + String msg = "Item was in collections:\n"; + List collsList = item.getCollections(); + for (Collection coll : collsList) { + msg = msg + coll.getName() + " (ID: " + coll.getID() + ")\n"; + } + return msg; + } + + public String getMessage(Bitstream bitstream) { + // values of deleted bitstream + String msg = bitstream.getName() + ": " + + bitstream.getSizeBytes() + " bytes, checksum: " + + bitstream.getChecksum() + " (" + + bitstream.getChecksumAlgorithm() + ")\n"; + return msg; + } + + public String getMessage(List resPolicies) { + return resPolicies.stream() + .filter(rp -> rp.getAction() == Constants.READ) + .map(rp -> String.format("[%s, %s, %d, %s, %s, %s, %s]", + rp.getRpName(), rp.getRpType(), rp.getAction(), + rp.getEPerson() != null ? rp.getEPerson().getEmail() : null, + rp.getGroup() != null ? rp.getGroup().getName() : null, + rp.getStartDate() != null ? rp.getStartDate().toString() : null, + rp.getEndDate() != null ? rp.getEndDate().toString() : null)) + .collect(Collectors.joining(";")); + } + + public String getMetadata(String oldMtdKey, String oldMtdValue) { + return oldMtdKey + ": " + oldMtdValue; + } + + public String getMetadataField(MetadataField metadataField) { + return metadataField.toString() + .replace('_', '.'); + } + + private String validateMessageTemplate(String messageTemplate, Object... args) { + if (messageTemplate == null) { + throw new IllegalArgumentException("The provenance message template is null!"); + } + return String.format(messageTemplate, args); + } +} diff --git a/dspace-api/src/main/java/org/dspace/core/ProvenanceMessageTemplates.java b/dspace-api/src/main/java/org/dspace/core/ProvenanceMessageTemplates.java new file mode 100644 index 000000000000..c14d3f447591 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/core/ProvenanceMessageTemplates.java @@ -0,0 +1,38 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.core; + +/** + * The ProvenanceMessageTemplates enum provides message templates for provenance messages. + * + * @author Michaela Paurikova (dspace at dataquest.sk) + */ +public enum ProvenanceMessageTemplates { + ACCESS_CONDITION("Access condition (%s) was added to %s (%s)"), + RESOURCE_POLICIES_REMOVED("Resource policies (%s) of %s (%s) were removed"), + BUNDLE_ADDED("Item was added bitstream to bundle (%s)"), + EDIT_LICENSE("License (%s) was %s"), + MOVE_ITEM("Item was moved from collection (%s) to different collection"), + MAPPED_ITEM("Item was mapped to collection (%s)"), + DELETED_ITEM_FROM_MAPPED("Item was deleted from mapped collection (%s)"), + EDIT_BITSTREAM("Item (%s) was deleted bitstream (%s)"), + ITEM_METADATA("Item metadata (%s) was %s"), + BITSTREAM_METADATA("Item metadata (%s) was %s bitstream (%s)"), + ITEM_REPLACE_SINGLE_METADATA("Item bitstream (%s) metadata (%s) was updated"), + DISCOVERABLE("Item was made %sdiscoverable"); + + private final String template; + + ProvenanceMessageTemplates(String template) { + this.template = template; + } + + public String getTemplate() { + return template; + } +} diff --git a/dspace-api/src/main/java/org/dspace/core/ProvenanceService.java b/dspace-api/src/main/java/org/dspace/core/ProvenanceService.java new file mode 100644 index 000000000000..9f34d89f9214 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/core/ProvenanceService.java @@ -0,0 +1,177 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.core; + +import java.util.List; + +import org.dspace.app.bulkaccesscontrol.model.BulkAccessControlInput; +import org.dspace.authorize.ResourcePolicy; +import org.dspace.content.Bitstream; +import org.dspace.content.Bundle; +import org.dspace.content.Collection; +import org.dspace.content.DSpaceObject; +import org.dspace.content.Item; +import org.dspace.content.MetadataField; +import org.dspace.content.MetadataValue; + +/** + * The ProvenanceService is responsible for creating provenance metadata for items based on the actions performed. + * + * @author Milan Majchrak (dspace at dataquest.sk) + */ +public interface ProvenanceService { + /** + * Add a provenance message to the item when a new access condition is added + * + * @param context DSpace context object + * @param item item to which the access condition is added + * @param accessControl the access control input + */ + void setItemPolicies(Context context, Item item, BulkAccessControlInput accessControl); + + /** + * Add a provenance message to the item when a read policy is removed + * + * @param context DSpace context object + * @param dso DSpace object from which the read policy is removed + * @param resPolicies list of resource policies that are removed + */ + void removeReadPolicies(Context context, DSpaceObject dso, List resPolicies); + + /** + * Add a provenance message to the item when a bitstream policy is set + * + * @param context DSpace context object + * @param bitstream bitstream to which the policy is set + * @param item item to which the bitstream belongs + * @param accessControl the access control input + */ + void setBitstreamPolicies(Context context, Bitstream bitstream, Item item, + BulkAccessControlInput accessControl); + + /** + * Add a provenance message to the item when an item's license is edited + * + * @param context DSpace context object + * @param item item to which the license is edited + * @param newLicense true if the license is new, false if it's edited + */ + void updateLicense(Context context, Item item, boolean newLicense); + + /** + * Add a provenance message to the item when it's moved to a collection + * + * @param context DSpace context object + * @param item item that is moved + * @param collection collection to which the item is moved + */ + void moveItem(Context context, Item item, Collection collection); + + /** + * Add a provenance message to the item when it's mapped to a collection + * + * @param context DSpace context object + * @param item item that is mapped + * @param collection collection to which the item is mapped + */ + void mappedItem(Context context, Item item, Collection collection); + + /** + * Add a provenance message to the item when it's deleted from a mapped collection + * + * @param context DSpace context object + * @param item item that is deleted from a mapped collection + * @param collection collection from which the item is deleted + */ + void deletedItemFromMapped(Context context, Item item, Collection collection); + + /** + * Add a provenance message to the item when it's bitstream is deleted + * + * @param context DSpace context object + * @param bitstream deleted bitstream + * @param item item from which the bitstream is deleted + */ + void deleteBitstream(Context context, Bitstream bitstream, Item item); + + /** + * Add a provenance message to the item when metadata is added + * + * @param context DSpace context object + * @param dso DSpace object to which the metadata is added + * @param metadataField metadata field that is added + */ + void addMetadata(Context context, DSpaceObject dso, MetadataField metadataField); + + /** + * Add a provenance message to the item when metadata is removed + * + * @param context DSpace context object + * @param dso DSpace object from which the metadata is removed + */ + void removeMetadata(Context context, DSpaceObject dso, String schema, String element, String qualifier); + + /** + * Add a provenance message to the item when metadata is removed at a given index + * + * @param context DSpace context object + * @param dso DSpace object from which the metadata is removed + * @param metadataValues list of metadata values + * @param indexInt index at which the metadata is removed + */ + void removeMetadataAtIndex(Context context, DSpaceObject dso, List metadataValues, + int indexInt); + + /** + * Add a provenance message to the item when metadata is replaced + * + * @param context DSpace context object + * @param dso DSpace object to which the metadata is replaced + * @param metadataField metadata field that is replaced + * @param oldMtdVal old metadata value + */ + void replaceMetadata(Context context, DSpaceObject dso, MetadataField metadataField, String oldMtdVal); + + /** + * Add a provenance message to the item when metadata is replaced + * + * @param context DSpace context object + * @param dso DSpace object to which the metadata is replaced + * @param metadataField metadata field that is replaced + * @param oldMtdVal old metadata value + */ + void replaceMetadataSingle(Context context, DSpaceObject dso, MetadataField metadataField, + String oldMtdVal); + + /** + * Add a provenance message to the item when metadata is updated + * + * @param context DSpace context object + * @param item item to which the metadata is updated + * @param discoverable true if the item is discoverable, false if it's not + */ + void makeDiscoverable(Context context, Item item, boolean discoverable); + + /** + * Add a provenance message to the item when a bitstream is uploaded + * + * @param context DSpace context object + * @param bundle bundle to which the bitstream is uploaded + */ + void uploadBitstream(Context context, Bundle bundle); + + /** + * Fetch an Item object using a service and return the first Item object from the list. + * Log an error if the list is empty or if there is an SQL error + * + * @param context DSpace context object + * @param bitstream bitstream to which the item is fetched + */ + Item findItemByBitstream(Context context, Bitstream bitstream); + +} diff --git a/dspace-api/src/main/java/org/dspace/core/ProvenanceServiceImpl.java b/dspace-api/src/main/java/org/dspace/core/ProvenanceServiceImpl.java new file mode 100644 index 000000000000..71b7128e5565 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/core/ProvenanceServiceImpl.java @@ -0,0 +1,353 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.core; + +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dspace.app.bulkaccesscontrol.model.AccessCondition; +import org.dspace.app.bulkaccesscontrol.model.BulkAccessControlInput; +import org.dspace.authorize.AuthorizeException; +import org.dspace.authorize.ResourcePolicy; +import org.dspace.content.Bitstream; +import org.dspace.content.Bundle; +import org.dspace.content.Collection; +import org.dspace.content.DSpaceObject; +import org.dspace.content.Item; +import org.dspace.content.MetadataField; +import org.dspace.content.MetadataSchemaEnum; +import org.dspace.content.MetadataValue; +import org.dspace.content.clarin.ClarinLicenseResourceMapping; +import org.dspace.content.service.BitstreamService; +import org.dspace.content.service.ItemService; +import org.dspace.content.service.clarin.ClarinItemService; +import org.dspace.content.service.clarin.ClarinLicenseResourceMappingService; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * ProvenanceServiceImpl is an implementation of ProvenanceService. + * + * @author Michaela Paurikova (dspace at dataquest.sk) + */ +public class ProvenanceServiceImpl implements ProvenanceService { + private static final Logger log = LogManager.getLogger(ProvenanceServiceImpl.class); + + @Autowired + private ItemService itemService; + @Autowired + private ClarinItemService clarinItemService; + @Autowired + private ClarinLicenseResourceMappingService clarinResourceMappingService; + @Autowired + private BitstreamService bitstreamService; + + private final ProvenanceMessageFormatter messageProvider = new ProvenanceMessageFormatter(); + + public void setItemPolicies(Context context, Item item, BulkAccessControlInput accessControl) { + String resPoliciesStr = extractAccessConditions(accessControl.getItem().getAccessConditions()); + if (StringUtils.isNotBlank(resPoliciesStr)) { + String msg = messageProvider.getMessage(context, ProvenanceMessageTemplates.ACCESS_CONDITION.getTemplate(), + resPoliciesStr, "item", item.getID()); + try { + addProvenanceMetadata(context, item, msg); + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when setting item policies.", e); + } + } + } + + public void removeReadPolicies(Context context, DSpaceObject dso, List resPolicies) { + if (resPolicies.isEmpty()) { + return; + } + String resPoliciesStr = messageProvider.getMessage(resPolicies); + try { + if (dso.getType() == Constants.ITEM) { + Item item = (Item) dso; + String msg = messageProvider.getMessage(context, + ProvenanceMessageTemplates.RESOURCE_POLICIES_REMOVED.getTemplate(), + resPoliciesStr.isEmpty() ? "empty" : resPoliciesStr, "item", item.getID()); + addProvenanceMetadata(context, item, msg); + } else if (dso.getType() == Constants.BITSTREAM) { + Bitstream bitstream = (Bitstream) dso; + Item item = findItemByBitstream(context, bitstream); + if (Objects.nonNull(item)) { + String msg = messageProvider.getMessage(context, + ProvenanceMessageTemplates.RESOURCE_POLICIES_REMOVED.getTemplate(), + resPoliciesStr.isEmpty() ? "empty" : resPoliciesStr, "bitstream", bitstream.getID()); + addProvenanceMetadata(context, item, msg); + } + } + } catch (SQLException | AuthorizeException e) { + log.error("Unable to remove read policies from the DSpace object.", e); + } + } + + public void setBitstreamPolicies(Context context, Bitstream bitstream, Item item, + BulkAccessControlInput accessControl) { + String accConditionsStr = extractAccessConditions(accessControl.getBitstream().getAccessConditions()); + if (StringUtils.isNotBlank(accConditionsStr)) { + String msg = messageProvider.getMessage(context, ProvenanceMessageTemplates.ACCESS_CONDITION.getTemplate(), + accConditionsStr, "bitstream", bitstream.getID()); + try { + addProvenanceMetadata(context, item, msg); + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when setting bitstream policies.", e); + } + } + } + + public void updateLicense(Context context, Item item, boolean newLicense) { + String oldLicense = null; + + try { + oldLicense = findLicenseInBundles(item, Constants.LICENSE_BUNDLE_NAME, oldLicense, context); + if (oldLicense == null) { + oldLicense = findLicenseInBundles(item, Constants.CONTENT_BUNDLE_NAME, oldLicense, context); + } + + String msg = messageProvider.getMessage(context, ProvenanceMessageTemplates.EDIT_LICENSE.getTemplate(), + item, Objects.isNull(oldLicense) ? "empty" : oldLicense, + !newLicense ? "removed" : Objects.isNull(oldLicense) ? "added" : "updated"); + addProvenanceMetadata(context, item, msg); + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when editing Item's license.", e); + } + + } + + public void moveItem(Context context, Item item, Collection collection) { + try { + String msg = messageProvider.getMessage(context, ProvenanceMessageTemplates.MOVE_ITEM.getTemplate(), + item, collection.getID()); + // Update item in DB + // Because a user can move an item without authorization turn off authorization + context.turnOffAuthorisationSystem(); + addProvenanceMetadata(context, item, msg); + context.restoreAuthSystemState(); + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when moving an item to a different collection.", + e); + } + } + + public void mappedItem(Context context, Item item, Collection collection) { + String msg = messageProvider.getMessage(context, ProvenanceMessageTemplates.MAPPED_ITEM.getTemplate(), + collection.getID()); + try { + addProvenanceMetadata(context, item, msg); + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when mapping an item into a collection.", e); + } + } + + public void deletedItemFromMapped(Context context, Item item, Collection collection) { + String msg = messageProvider.getMessage(context, + ProvenanceMessageTemplates.DELETED_ITEM_FROM_MAPPED.getTemplate(), collection.getID()); + try { + addProvenanceMetadata(context, item, msg); + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when deleting an item from a mapped collection.", + e); + } + } + + public void deleteBitstream(Context context, Bitstream bitstream, Item item) { + try { + if (Objects.nonNull(item)) { + String msg = messageProvider.getMessage(context, + ProvenanceMessageTemplates.EDIT_BITSTREAM.getTemplate(), item, item.getID(), + messageProvider.getMessage(bitstream)); + addProvenanceMetadata(context, item, msg); + } + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when deleting a bitstream.", e); + } + } + + public void addMetadata(Context context, DSpaceObject dso, MetadataField metadataField) { + try { + if (Constants.ITEM == dso.getType()) { + String msg = messageProvider.getMessage(context, ProvenanceMessageTemplates.ITEM_METADATA.getTemplate(), + messageProvider.getMetadataField(metadataField), "added"); + addProvenanceMetadata(context, (Item) dso, msg); + } + + if (dso.getType() == Constants.BITSTREAM) { + Bitstream bitstream = (Bitstream) dso; + Item item = findItemByBitstream(context, bitstream); + if (Objects.nonNull(item)) { + String msg = messageProvider.getMessage(context, + ProvenanceMessageTemplates.BITSTREAM_METADATA.getTemplate(), item, + messageProvider.getMetadataField(metadataField), "added by", + messageProvider.getMessage(bitstream)); + addProvenanceMetadata(context, item, msg); + } + } + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when adding metadata to a DSpace object.", e); + } + } + + public void removeMetadata(Context context, DSpaceObject dso, String schema, String element, String qualifier) { + if (dso.getType() != Constants.BITSTREAM) { + return; + } + MetadataField oldMtdKey = null; + String oldMtdValue = null; + List mtd = bitstreamService.getMetadata((Bitstream) dso, schema, element, qualifier, Item.ANY); + if (CollectionUtils.isEmpty(mtd)) { + // Do not add any provenance message when there are no metadata to remove + return; + } + oldMtdKey = mtd.get(0).getMetadataField(); + oldMtdValue = mtd.get(0).getValue(); + Bitstream bitstream = (Bitstream) dso; + try { + Item item = findItemByBitstream(context, bitstream); + if (Objects.nonNull(item)) { + String msg = messageProvider.getMessage(context, + ProvenanceMessageTemplates.BITSTREAM_METADATA.getTemplate(), item, + messageProvider.getMetadata(messageProvider.getMetadataField(oldMtdKey), oldMtdValue), + "deleted from", messageProvider.getMessage(bitstream)); + addProvenanceMetadata(context, item, msg); + } + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when removing metadata from a dso.", e); + } + + } + + public void removeMetadataAtIndex(Context context, DSpaceObject dso, List metadataValues, + int indexInt) { + if (dso.getType() != Constants.ITEM) { + return; + } + // Remember removed mtd + String oldMtdKey = messageProvider.getMetadataField(metadataValues.get(indexInt).getMetadataField()); + String oldMtdValue = metadataValues.get(indexInt).getValue(); + try { + String msg = messageProvider.getMessage(context, ProvenanceMessageTemplates.ITEM_METADATA.getTemplate(), + (Item) dso, messageProvider.getMetadata(oldMtdKey, oldMtdValue), "deleted"); + addProvenanceMetadata(context, (Item) dso, msg); + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when removing metadata at a specific index " + + "from a dso", e); + } + } + + public void replaceMetadata(Context context, DSpaceObject dso, MetadataField metadataField, String oldMtdVal) { + if (dso.getType() != Constants.ITEM) { + return; + } + try { + String msg = messageProvider.getMessage(context, ProvenanceMessageTemplates.ITEM_METADATA.getTemplate(), + (Item) dso,messageProvider.getMetadata(messageProvider.getMetadataField(metadataField), + oldMtdVal), "updated"); + addProvenanceMetadata(context, (Item) dso, msg); + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when replacing metadata in a dso.", e); + } + + } + + public void replaceMetadataSingle(Context context, DSpaceObject dso, MetadataField metadataField, + String oldMtdVal) { + if (dso.getType() != Constants.BITSTREAM) { + return; + } + + Bitstream bitstream = (Bitstream) dso; + try { + Item item = findItemByBitstream(context, bitstream); + if (Objects.nonNull(item)) { + String msg = messageProvider.getMessage(context, + ProvenanceMessageTemplates.ITEM_REPLACE_SINGLE_METADATA.getTemplate(), item, + messageProvider.getMessage(bitstream), + messageProvider.getMetadata(messageProvider.getMetadataField(metadataField), oldMtdVal)); + addProvenanceMetadata(context, item, msg);; + } + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when replacing metadata in a item.", e); + } + } + + public void makeDiscoverable(Context context, Item item, boolean discoverable) { + try { + String msg = messageProvider.getMessage(context, ProvenanceMessageTemplates.DISCOVERABLE.getTemplate(), + item, discoverable ? "" : "non-") + messageProvider.getMessage(item); + addProvenanceMetadata(context, item, msg); + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when making an item discoverable.", e); + } + } + + public void uploadBitstream(Context context, Bundle bundle) { + Item item = bundle.getItems().get(0); + try { + String msg = messageProvider.getMessage(context, ProvenanceMessageTemplates.BUNDLE_ADDED.getTemplate(), + item, bundle.getID()); + addProvenanceMetadata(context,item, msg); + itemService.update(context, item); + } catch (SQLException | AuthorizeException e) { + log.error("Unable to add new provenance metadata when updating an item's bitstream.", e); + } + } + + private void addProvenanceMetadata(Context context, Item item, String msg) + throws SQLException, AuthorizeException { + itemService.addMetadata(context, item, MetadataSchemaEnum.DC.getName(), + "description", "provenance", "en", msg); + itemService.update(context, item); + } + + private String extractAccessConditions(List accessConditions) { + return accessConditions.stream() + .map(AccessCondition::getName) + .collect(Collectors.joining(";")); + } + + public Item findItemByBitstream(Context context, Bitstream bitstream) { + List items = null; + try { + items = clarinItemService.findByBitstreamUUID(context, bitstream.getID()); + } catch (SQLException e) { + log.error("Unable to find item by bitstream (" + bitstream.getID() + " ).", e); + return null; + } + if (items.isEmpty()) { + log.warn("Bitstream (" + bitstream.getID() + ") is not assigned to any item."); + return null; + } + return items.get(0); + } + + private String findLicenseInBundles(Item item, String bundleName, String currentLicense, Context context) + throws SQLException { + List bundles = item.getBundles(bundleName); + for (Bundle clarinBundle : bundles) { + List bitstreamList = clarinBundle.getBitstreams(); + for (Bitstream bundleBitstream : bitstreamList) { + if (Objects.isNull(currentLicense)) { + List mappings = + this.clarinResourceMappingService.findByBitstreamUUID(context, bundleBitstream.getID()); + if (CollectionUtils.isNotEmpty(mappings)) { + return mappings.get(0).getLicense().getName(); + } + } + } + } + return currentLicense; + } +} diff --git a/dspace-api/src/main/java/org/dspace/core/Utils.java b/dspace-api/src/main/java/org/dspace/core/Utils.java index 6831f45b5c51..3488ebd275ab 100644 --- a/dspace-api/src/main/java/org/dspace/core/Utils.java +++ b/dspace-api/src/main/java/org/dspace/core/Utils.java @@ -41,6 +41,9 @@ import org.apache.logging.log4j.Logger; import org.dspace.services.ConfigurationService; import org.dspace.services.factory.DSpaceServicesFactory; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.query.NativeQuery; /** * Utility functions for DSpace. @@ -523,4 +526,26 @@ public static String replaceLast(String input, String toReplace, String replacem return input.substring(0, lastIndex) + replacement + input.substring(lastIndex + toReplace.length()); } + + /** + * Get the current transaction's PID from PostgreSQL + + * @return PID of the current transaction + */ + public static Integer getTransactionPid(SessionFactory sessionFactory) { + Integer pid = -1; + try { + Session session = sessionFactory.getCurrentSession(); // Get the current session + String sql = "SELECT pg_backend_pid()"; // SQL query to get the PID + + // Execute the query and get the PID + NativeQuery query = session.createNativeQuery(sql); + pid = query.getSingleResult(); // Get the single result + + log.info("Current transaction PID: " + pid); // Optional logging + } catch (Exception e) { + log.error("Cannot get PID because: " + e.getMessage()); + } + return pid; + } } diff --git a/dspace-api/src/main/java/org/dspace/health/Check.java b/dspace-api/src/main/java/org/dspace/health/Check.java index 40f29c15f73a..89b01ca95685 100644 --- a/dspace-api/src/main/java/org/dspace/health/Check.java +++ b/dspace-api/src/main/java/org/dspace/health/Check.java @@ -50,4 +50,15 @@ protected void error(Throwable e, String msg) { } } + public String getErrors() { + return errors_; + } + + public String getReport() { + return report_; + } + + public long getTook() { + return took_; + } } diff --git a/dspace-api/src/test/data/dspaceFolder/config/local.cfg b/dspace-api/src/test/data/dspaceFolder/config/local.cfg index 14ff9e3a72a3..0f9daea975e4 100644 --- a/dspace-api/src/test/data/dspaceFolder/config/local.cfg +++ b/dspace-api/src/test/data/dspaceFolder/config/local.cfg @@ -319,3 +319,8 @@ autocomplete.custom.separator.solr-subject_ac = \\|\\|\\| autocomplete.custom.separator.solr-title_ac = \\|\\|\\| autocomplete.custom.allowed = solr-author_ac,solr-publisher_ac,solr-dataProvider_ac,solr-dctype_ac,solr-subject_ac,solr-handle_title_ac,json_static-iso_langs.json,solr-title_ac +##### METADATA EDIT ##### +#### name || id +### these collections allow submitters to edit metadata of their items +# property is not commented, because of tests +allow.edit.metadata = \ No newline at end of file diff --git a/dspace-api/src/test/data/dspaceFolder/config/spring/api/scripts.xml b/dspace-api/src/test/data/dspaceFolder/config/spring/api/scripts.xml index 738e11f7b432..ce760106fbf0 100644 --- a/dspace-api/src/test/data/dspaceFolder/config/spring/api/scripts.xml +++ b/dspace-api/src/test/data/dspaceFolder/config/spring/api/scripts.xml @@ -96,4 +96,9 @@ + + + + + diff --git a/dspace-api/src/test/java/org/dspace/builder/ClarinLicenseResourceMappingBuilder.java b/dspace-api/src/test/java/org/dspace/builder/ClarinLicenseResourceMappingBuilder.java index 4a39a44fd4b1..c2bd6ab77831 100644 --- a/dspace-api/src/test/java/org/dspace/builder/ClarinLicenseResourceMappingBuilder.java +++ b/dspace-api/src/test/java/org/dspace/builder/ClarinLicenseResourceMappingBuilder.java @@ -8,6 +8,7 @@ package org.dspace.builder; import java.sql.SQLException; +import java.util.Objects; import org.dspace.authorize.AuthorizeException; import org.dspace.content.clarin.ClarinLicenseResourceMapping; @@ -37,6 +38,20 @@ private ClarinLicenseResourceMappingBuilder create(final Context context) { return this; } + public static void delete(Integer id) throws Exception { + if (Objects.isNull(id)) { + return; + } + try (Context c = new Context()) { + ClarinLicenseResourceMapping clarinLicense = clarinLicenseResourceMappingService.find(c, id); + + if (clarinLicense != null) { + clarinLicenseResourceMappingService.delete(c, clarinLicense); + } + c.complete(); + } + } + @Override public void cleanup() throws Exception { try (Context c = new Context()) { diff --git a/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java b/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java new file mode 100644 index 000000000000..c4e732d49990 --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java @@ -0,0 +1,43 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.scripts; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; + +import java.util.List; + +import org.dspace.AbstractIntegrationTestWithDatabase; +import org.dspace.app.launcher.ScriptLauncher; +import org.dspace.app.scripts.handler.impl.TestDSpaceRunnableHandler; +import org.junit.Test; + +/** + * Integration test for the HealthReport script + * @author Milan Majchrak (milan.majchrak at dataquest.sk) + */ +public class HealthReportIT extends AbstractIntegrationTestWithDatabase { + @Test + public void testDefaultHealthcheckRun() throws Exception { + + TestDSpaceRunnableHandler testDSpaceRunnableHandler = new TestDSpaceRunnableHandler(); + + String[] args = new String[] { "health-report" }; + ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), testDSpaceRunnableHandler, kernelImpl); + + assertThat(testDSpaceRunnableHandler.getErrorMessages(), empty()); + assertThat(testDSpaceRunnableHandler.getWarningMessages(), empty()); + + List messages = testDSpaceRunnableHandler.getInfoMessages(); + assertThat(messages, hasSize(1)); + assertThat(messages, hasItem(containsString("HEALTH REPORT:"))); + } +} diff --git a/dspace-oai/src/main/java/org/dspace/xoai/app/XOAI.java b/dspace-oai/src/main/java/org/dspace/xoai/app/XOAI.java index 4930dd5956c3..764da35fbf05 100644 --- a/dspace-oai/src/main/java/org/dspace/xoai/app/XOAI.java +++ b/dspace-oai/src/main/java/org/dspace/xoai/app/XOAI.java @@ -431,8 +431,8 @@ private SolrInputDocument index(Item item) if (!discoverable && item.isHidden()) { discoverable = true; } - doc.addField("item.deleted", - (item.isWithdrawn() || (!discoverable) || (isEmbargoed ? isPublic : false))); + boolean isDeleted = item.isWithdrawn() || (!discoverable) || (isEmbargoed && isPublic); + doc.addField("item.deleted", isDeleted); /* * An item that is embargoed will potentially not be harvested by incremental diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ItemAddBundleController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ItemAddBundleController.java index b3444a739e77..74f28bbfd19c 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ItemAddBundleController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ItemAddBundleController.java @@ -38,6 +38,7 @@ import org.dspace.content.service.clarin.ClarinLicenseService; import org.dspace.core.Constants; import org.dspace.core.Context; +import org.dspace.core.ProvenanceService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -98,6 +99,9 @@ public class ItemAddBundleController { @Autowired ClarinLicenseResourceMappingService clarinLicenseResourceMappingService; + @Autowired + ProvenanceService provenanceService; + /** * Method to add a Bundle to an Item with the given UUID in the URL. This will create a Bundle with the * name provided in the request and attach this to the Item that matches the UUID in the URL. @@ -183,6 +187,7 @@ public ItemRest updateLicenseForBundle(@PathVariable UUID uuid, } itemService.update(context, item); + provenanceService.updateLicense(context, item, !Objects.isNull(clarinLicense)); context.commit(); return converter.toRest(item, utils.obtainProjection()); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ItemOwningCollectionUpdateRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ItemOwningCollectionUpdateRestController.java index b5a0c957f265..109b79c86111 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ItemOwningCollectionUpdateRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ItemOwningCollectionUpdateRestController.java @@ -32,6 +32,7 @@ import org.dspace.content.service.ItemService; import org.dspace.core.Constants; import org.dspace.core.Context; +import org.dspace.core.ProvenanceService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.rest.webmvc.ResourceNotFoundException; import org.springframework.security.access.prepost.PostAuthorize; @@ -65,6 +66,9 @@ public class ItemOwningCollectionUpdateRestController { @Autowired Utils utils; + @Autowired + ProvenanceService provenanceService; + /** * This method will update the owning collection of the item that correspond to the provided item uuid, effectively * moving the item to the new collection. diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/MappedCollectionRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/MappedCollectionRestController.java index 14dae21ebec0..09581f5e2998 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/MappedCollectionRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/MappedCollectionRestController.java @@ -30,6 +30,7 @@ import org.dspace.content.service.CollectionService; import org.dspace.content.service.ItemService; import org.dspace.core.Context; +import org.dspace.core.ProvenanceService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.PathVariable; @@ -57,6 +58,9 @@ public class MappedCollectionRestController { @Autowired Utils utils; + @Autowired + ProvenanceService provenanceService; + /** * This method will add an Item to a Collection. The Collection object is encapsulated in the request due to the * text/uri-list consumer and the Item UUID comes from the path in the URL @@ -105,6 +109,7 @@ public void createCollectionToItemRelation(@PathVariable UUID uuid, collectionService.addItem(context, collectionToMapTo, item); collectionService.update(context, collectionToMapTo); itemService.update(context, item); + provenanceService.mappedItem(context, item, collectionToMapTo); } else { throw new UnprocessableEntityException("Not a valid collection or item uuid."); } @@ -151,12 +156,12 @@ public void deleteCollectionToItemRelation(@PathVariable UUID uuid, @PathVariabl collectionService.removeItem(context, collection, item); collectionService.update(context, collection); itemService.update(context, item); + provenanceService.deletedItemFromMapped(context,item, collection); context.commit(); } } else { throw new UnprocessableEntityException("Not a valid collection or item uuid."); } - } private void checkIfItemIsTemplate(Item item) { diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BundleRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BundleRestRepository.java index f750743db66e..7f800ea81813 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BundleRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BundleRestRepository.java @@ -35,6 +35,7 @@ import org.dspace.content.service.ItemService; import org.dspace.core.Constants; import org.dspace.core.Context; +import org.dspace.core.ProvenanceService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -69,6 +70,9 @@ public class BundleRestRepository extends DSpaceObjectRestRepository findByValue(@Parameter(value = "schema", r searchValue = searchValue.replace(":", ""); } + List metadataValueWrappers = new ArrayList<>(); + // Perform a search, but only retrieve the total count of results, not the actual objects + DiscoverResult searchResult = createAndRunDiscoverResult(context, metadataField, searchValue, 0); + long totalResultsLong = searchResult.getTotalSearchResults(); + // Safe conversion from long to int + int totalResults = (totalResultsLong > Integer.MAX_VALUE) ? + Integer.MAX_VALUE : (int) totalResultsLong; + // Perform the search again, this time retrieving the actual results based on the total count + searchResult = createAndRunDiscoverResult(context, metadataField, searchValue, totalResults); + for (IndexableObject object : searchResult.getIndexableObjects()) { + if (object instanceof IndexableItem) { + // Get the item which has the metadata with the search value + List metadataValues = itemService.getMetadataByMetadataString( + ((IndexableItem) object).getIndexedObject(), metadataField); + + // The Item could have more metadata than the metadata with searching value, filter that metadata + String finalSearchValue = searchValue; + List filteredMetadataValues = metadataValues.stream() + .filter(metadataValue -> metadataValue.getValue().contains(finalSearchValue)) + .collect(Collectors.toList()); + + // convert metadata values to the wrapper + List metadataValueWrapperList = + this.convertMetadataValuesToWrappers(filteredMetadataValues); + metadataValueWrappers.addAll(metadataValueWrapperList); + } + } + + // filter eu sponsor -> do not return eu sponsor suggestions for items where eu sponsor is used. + // openAIRE API + if (StringUtils.equals(schemaName, "local") && StringUtils.equals(elementName, "sponsor")) { + metadataValueWrappers = filterEUSponsors(metadataValueWrappers); + } + metadataValueWrappers = distinctMetadataValues(metadataValueWrappers); + + return converter.toRestPage(metadataValueWrappers, pageable, utils.obtainProjection()); + } + + /** + * Create a discover query and retrieve the results from the Solr Search core. + */ + private DiscoverResult createAndRunDiscoverResult(Context context, String metadataField, + String searchValue, int maxResults) { // Find matches in Solr Search core DiscoverQuery discoverQuery = - this.createDiscoverQuery(metadataField, searchValue); + this.createDiscoverQuery(metadataField, searchValue, maxResults); if (ObjectUtils.isEmpty(discoverQuery)) { throw new IllegalArgumentException("Cannot create a DiscoverQuery from the arguments."); @@ -124,41 +167,12 @@ public Page findByValue(@Parameter(value = "schema", r if (StringUtils.isNotBlank(normalizedQuery)) { discoverQuery.setQuery(normalizedQuery); } - - List metadataValueWrappers = new ArrayList<>(); try { - DiscoverResult searchResult = searchService.search(context, discoverQuery); - for (IndexableObject object : searchResult.getIndexableObjects()) { - if (object instanceof IndexableItem) { - // Get the item which has the metadata with the search value - List metadataValues = itemService.getMetadataByMetadataString( - ((IndexableItem) object).getIndexedObject(), metadataField); - - // The Item could have more metadata than the metadata with searching value, filter that metadata - String finalSearchValue = searchValue; - List filteredMetadataValues = metadataValues.stream() - .filter(metadataValue -> metadataValue.getValue().contains(finalSearchValue)) - .collect(Collectors.toList()); - - // convert metadata values to the wrapper - List metadataValueWrapperList = - this.convertMetadataValuesToWrappers(filteredMetadataValues); - metadataValueWrappers.addAll(metadataValueWrapperList); - } - } + return searchService.search(context, discoverQuery); } catch (SearchServiceException e) { log.error("Error while searching with Discovery", e); throw new IllegalArgumentException("Error while searching with Discovery: " + e.getMessage()); } - - // filter eu sponsor -> do not return eu sponsor suggestions for items where eu sponsor is used. - // openAIRE API - if (StringUtils.equals(schemaName, "local") && StringUtils.equals(elementName, "sponsor")) { - metadataValueWrappers = filterEUSponsors(metadataValueWrappers); - } - metadataValueWrappers = distinctMetadataValues(metadataValueWrappers); - - return converter.toRestPage(metadataValueWrappers, pageable, utils.obtainProjection()); } public List filterEUSponsors(List metadataWrappers) { @@ -172,11 +186,10 @@ public List distinctMetadataValues(List convertMetadataValuesToWrappers(List loadSuggestionsFromSolr(String autocompleteCus DiscoverQuery discoverQuery = new DiscoverQuery(); // Process the custom query if it contains the specific query parameter `?query=` autocompleteCustom = updateAutocompleteAndQuery(autocompleteCustom, discoverQuery); - // TODO - search facets and process facet results instead of indexable objects - discoverQuery.setMaxResults(500); + DiscoverFacetField facetField = new DiscoverFacetField(autocompleteCustom, + DiscoveryConfigurationParameters.TYPE_STANDARD, + -1, // no limit (get all facet values) + DiscoveryConfigurationParameters.SORT.VALUE // sorting order + ); + discoverQuery.addFacetField(facetField); // return only metadata field values discoverQuery.addSearchField(autocompleteCustom); @@ -255,30 +260,17 @@ private List loadSuggestionsFromSolr(String autocompleteCus */ private void processSolrSearchResults(DiscoverResult searchResult, String autocompleteCustom, String searchValue, List results) { - searchResult.getIndexableObjects().forEach(object -> { - if (!(object instanceof IndexableItem)) { - return; - } - IndexableItem item = (IndexableItem) object; - // Get all search documents for the item. - searchResult.getSearchDocument(item).forEach((searchDocument) -> { + searchResult.getFacetResult(autocompleteCustom).forEach(facetResult -> { + String displayedValue = facetResult.getDisplayedValue(); + if (displayedValue.contains(searchValue)) { + // Create a new VocabularyEntryRest object VocabularyEntryRest vocabularyEntryRest = new VocabularyEntryRest(); - // All values from Item's specific index - it could contain values we are not looking for. - // The must be filtered out. - List docValues = searchDocument.getSearchFieldValues(autocompleteCustom); - - // Filter values that contain searchValue - List filteredValues = docValues.stream() - .filter(value -> value.contains(searchValue)) - .collect(Collectors.toList()); - - // Add filtered values to the results. It contains only values that contain searchValue. - filteredValues.forEach(value -> { - vocabularyEntryRest.setDisplay(value); - vocabularyEntryRest.setValue(value); - results.add(vocabularyEntryRest); - }); - }); + vocabularyEntryRest.setDisplay(displayedValue); + vocabularyEntryRest.setValue(displayedValue); + + // Add the filtered value to the results + results.add(vocabularyEntryRest); + } }); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/BitstreamRemoveOperation.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/BitstreamRemoveOperation.java index b0e2a45c9d23..0e38b453625c 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/BitstreamRemoveOperation.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/BitstreamRemoveOperation.java @@ -16,9 +16,11 @@ import org.dspace.authorize.AuthorizeException; import org.dspace.authorize.service.AuthorizeService; import org.dspace.content.Bitstream; +import org.dspace.content.Item; import org.dspace.content.service.BitstreamService; import org.dspace.core.Constants; import org.dspace.core.Context; +import org.dspace.core.ProvenanceService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Component; @@ -43,6 +45,8 @@ public class BitstreamRemoveOperation extends PatchOperation { BitstreamService bitstreamService; @Autowired AuthorizeService authorizeService; + @Autowired + ProvenanceService provenanceService; public static final String OPERATION_PATH_BITSTREAM_REMOVE = "/bitstreams/"; @Override @@ -53,9 +57,14 @@ public Bitstream perform(Context context, Bitstream resource, Operation operatio throw new RESTBitstreamNotFoundException(bitstreamIDtoDelete); } authorizeBitstreamRemoveAction(context, bitstreamToDelete, Constants.DELETE); - try { + // Find the item to which the bitstream belongs before deleting the bitstream, + // because after deletion, the item will no longer be connected to the bitstream. + Item item = provenanceService.findItemByBitstream(context, bitstreamToDelete); + // Delete the bitstream bitstreamService.delete(context, bitstreamToDelete); + // Update the provenance metadata after the bitstream has been successfully deleted + provenanceService.deleteBitstream(context, bitstreamToDelete, item); } catch (AuthorizeException | IOException e) { throw new RuntimeException(e.getMessage(), e); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/DSpaceObjectMetadataAddOperation.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/DSpaceObjectMetadataAddOperation.java index 4b27ae963ab0..4f343019942b 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/DSpaceObjectMetadataAddOperation.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/DSpaceObjectMetadataAddOperation.java @@ -17,6 +17,9 @@ import org.dspace.content.factory.ContentServiceFactory; import org.dspace.content.service.DSpaceObjectService; import org.dspace.core.Context; +import org.dspace.core.ProvenanceService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -34,8 +37,11 @@ @Component public class DSpaceObjectMetadataAddOperation extends PatchOperation { + private static final Logger log = LoggerFactory.getLogger(DSpaceObjectMetadataAddOperation.class); @Autowired DSpaceObjectMetadataPatchUtils metadataPatchUtils; + @Autowired + ProvenanceService provenanceService; @Override public R perform(Context context, R resource, Operation operation) throws SQLException { @@ -69,9 +75,13 @@ private void add(Context context, DSpaceObject dso, DSpaceObjectService dsoServi dsoService.addAndShiftRightMetadata(context, dso, metadataField.getMetadataSchema().getName(), metadataField.getElement(), metadataField.getQualifier(), metadataValue.getLanguage(), metadataValue.getValue(), metadataValue.getAuthority(), metadataValue.getConfidence(), indexInt); + provenanceService.addMetadata(context, dso, metadataField); } catch (SQLException e) { - throw new DSpaceBadRequestException("SQLException in DspaceObjectMetadataAddOperation.add trying to add " + - "metadata to dso.", e); + String msg; + msg = "SQLException in DspaceObjectMetadataAddOperation.add trying to add " + + "metadata to dso."; + log.error(msg, e); + throw new DSpaceBadRequestException(msg, e); } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/DSpaceObjectMetadataRemoveOperation.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/DSpaceObjectMetadataRemoveOperation.java index 3164ae377aeb..2d09150233e3 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/DSpaceObjectMetadataRemoveOperation.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/DSpaceObjectMetadataRemoveOperation.java @@ -21,6 +21,9 @@ import org.dspace.content.factory.ContentServiceFactory; import org.dspace.content.service.DSpaceObjectService; import org.dspace.core.Context; +import org.dspace.core.ProvenanceService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -41,8 +44,11 @@ @Component public class DSpaceObjectMetadataRemoveOperation extends PatchOperation { + private static final Logger log = LoggerFactory.getLogger(DSpaceObjectMetadataRemoveOperation.class); @Autowired DSpaceObjectMetadataPatchUtils metadataPatchUtils; + @Autowired + ProvenanceService provenanceService; @Override public R perform(Context context, R resource, Operation operation) throws SQLException { @@ -82,6 +88,7 @@ private void remove(Context context, DSpaceObject dso, DSpaceObjectService dsoSe // remove that metadata dsoService.removeMetadataValues(context, dso, Arrays.asList(metadataValues.get(indexInt))); + provenanceService.removeMetadataAtIndex(context, dso, metadataValues, indexInt); } else { throw new UnprocessableEntityException("UnprocessableEntityException - There is no metadata of " + "this type at that index"); @@ -91,9 +98,9 @@ private void remove(Context context, DSpaceObject dso, DSpaceObjectService dsoSe throw new IllegalArgumentException("This index (" + index + ") is not valid number.", e); } catch (ArrayIndexOutOfBoundsException e) { throw new UnprocessableEntityException("There is no metadata of this type at that index"); - } catch (SQLException e) { - throw new DSpaceBadRequestException("SQLException in DspaceObjectMetadataRemoveOperation.remove " + - "trying to remove metadata from dso.", e); + } catch (SQLException ex) { + throw new DSpaceBadRequestException("SQLException in DspaceObjectMetadataRemoveOperation.remove" + + " trying to remove metadata from dso.", ex); } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/DSpaceObjectMetadataReplaceOperation.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/DSpaceObjectMetadataReplaceOperation.java index 1cf15684587b..129c99a3c9cd 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/DSpaceObjectMetadataReplaceOperation.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/DSpaceObjectMetadataReplaceOperation.java @@ -21,6 +21,9 @@ import org.dspace.content.factory.ContentServiceFactory; import org.dspace.content.service.DSpaceObjectService; import org.dspace.core.Context; +import org.dspace.core.ProvenanceService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -38,9 +41,11 @@ */ @Component public class DSpaceObjectMetadataReplaceOperation extends PatchOperation { - + private static final Logger log = LoggerFactory.getLogger(DSpaceObjectMetadataReplaceOperation.class); @Autowired DSpaceObjectMetadataPatchUtils metadataPatchUtils; + @Autowired + ProvenanceService provenanceService; @Override public R perform(Context context, R resource, Operation operation) throws SQLException { @@ -91,11 +96,12 @@ private void replace(Context context, DSpaceObject dso, DSpaceObjectService dsoS } // replace single existing metadata value if (propertyOfMd == null) { - this.replaceSingleMetadataValue(dso, dsoService, metadataField, metadataValue, index); + this.replaceSingleMetadataValue(context, dso, dsoService, metadataField, metadataValue, index); return; } // replace single property of exiting metadata value - this.replaceSinglePropertyOfMdValue(dso, dsoService, metadataField, index, propertyOfMd, valueMdProperty); + this.replaceSinglePropertyOfMdValue(context, dso, dsoService, metadataField, + index, propertyOfMd, valueMdProperty); } /** @@ -145,9 +151,10 @@ private void replaceMetadataFieldMetadata(Context context, DSpaceObject dso, DSp * @param index index of md being replaced */ // replace single existing metadata value - private void replaceSingleMetadataValue(DSpaceObject dso, DSpaceObjectService dsoService, + private void replaceSingleMetadataValue(Context context, DSpaceObject dso, DSpaceObjectService dsoService, MetadataField metadataField, MetadataValueRest metadataValue, String index) { + String msg; try { List metadataValues = dsoService.getMetadata(dso, metadataField.getMetadataSchema().getName(), metadataField.getElement(), @@ -157,11 +164,13 @@ private void replaceSingleMetadataValue(DSpaceObject dso, DSpaceObjectService ds && metadataValues.get(indexInt) != null) { // Alter this existing md MetadataValue existingMdv = metadataValues.get(indexInt); + String oldMtdVal = existingMdv.getValue(); existingMdv.setAuthority(metadataValue.getAuthority()); existingMdv.setConfidence(metadataValue.getConfidence()); existingMdv.setLanguage(metadataValue.getLanguage()); existingMdv.setValue(metadataValue.getValue()); dsoService.setMetadataModified(dso); + provenanceService.replaceMetadata(context, dso, metadataField, oldMtdVal); } else { throw new UnprocessableEntityException("There is no metadata of this type at that index"); } @@ -179,7 +188,7 @@ private void replaceSingleMetadataValue(DSpaceObject dso, DSpaceObjectService ds * @param propertyOfMd property of md being replaced * @param valueMdProperty new value of property of md being replaced */ - private void replaceSinglePropertyOfMdValue(DSpaceObject dso, DSpaceObjectService dsoService, + private void replaceSinglePropertyOfMdValue(Context context, DSpaceObject dso, DSpaceObjectService dsoService, MetadataField metadataField, String index, String propertyOfMd, String valueMdProperty) { try { @@ -190,6 +199,8 @@ private void replaceSinglePropertyOfMdValue(DSpaceObject dso, DSpaceObjectServic if (indexInt >= 0 && metadataValues.size() > indexInt && metadataValues.get(indexInt) != null) { // Alter only asked propertyOfMd MetadataValue existingMdv = metadataValues.get(indexInt); + String oldMtdVal = existingMdv.getValue(); + if (propertyOfMd.equals("authority")) { existingMdv.setAuthority(valueMdProperty); } @@ -203,6 +214,7 @@ private void replaceSinglePropertyOfMdValue(DSpaceObject dso, DSpaceObjectServic existingMdv.setValue(valueMdProperty); } dsoService.setMetadataModified(dso); + provenanceService.replaceMetadataSingle(context, dso, metadataField, oldMtdVal); } else { throw new UnprocessableEntityException("There is no metadata of this type at that index"); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/ItemDiscoverableReplaceOperation.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/ItemDiscoverableReplaceOperation.java index df17d4e92da3..22f136638c32 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/ItemDiscoverableReplaceOperation.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/patch/operation/ItemDiscoverableReplaceOperation.java @@ -12,6 +12,8 @@ import org.dspace.app.rest.model.patch.Operation; import org.dspace.content.Item; import org.dspace.core.Context; +import org.dspace.core.ProvenanceService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; /** @@ -31,6 +33,9 @@ public class ItemDiscoverableReplaceOperation extends PatchOperation { */ private static final String OPERATION_PATH_DISCOVERABLE = "/discoverable"; + @Autowired + ProvenanceService provenanceService; + @Override public R perform(Context context, R object, Operation operation) { checkOperationValue(operation.getValue()); @@ -41,6 +46,7 @@ public R perform(Context context, R object, Operation operation) { throw new UnprocessableEntityException("A template item cannot be discoverable."); } item.setDiscoverable(discoverable); + provenanceService.makeDiscoverable(context, item, discoverable); return object; } else { throw new DSpaceBadRequestException("ItemDiscoverableReplaceOperation does not support this operation"); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/StatelessAuthenticationFilter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/StatelessAuthenticationFilter.java index 964d35f42c34..32169fda4a8c 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/StatelessAuthenticationFilter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/StatelessAuthenticationFilter.java @@ -99,7 +99,22 @@ protected void doFilterInternal(HttpServletRequest req, if (authentication != null) { SecurityContextHolder.getContext().setAuthentication(authentication); } - chain.doFilter(req, res); + + try { + chain.doFilter(req, res); + } finally { + // Complete the context to avoid transactions getting stuck in the connection pool in the + // `idle in transaction` state. + Context context = (Context) req.getAttribute(ContextUtil.DSPACE_CONTEXT); + // Ensure the context is cleared after the request is done + if (context != null && context.isValid()) { + try { + context.abort(); + } catch (Exception e) { + log.error("{} occurred while trying to close", e.getMessage(), e); + } + } + } } /** diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/SolrOAIReindexer.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/SolrOAIReindexer.java index 4e97b898abfd..c3cb1290c0db 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/SolrOAIReindexer.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/SolrOAIReindexer.java @@ -28,6 +28,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrQuery; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.common.SolrDocumentList; @@ -188,12 +189,14 @@ private SolrInputDocument index(Item item) throws SQLException, IOException, XML * invisible embargoed items, because this will override the item.public * flag. */ - boolean deleted = false; - if (!item.isHidden()) { - deleted = (item.isWithdrawn() || !item.isDiscoverable() || (isEmbargoed && isPublic)); + boolean discoverable = item.isDiscoverable(); + // The Item is not deleted when it has local metadata `local.hidden = hidden`. + // Without this, the item is not discoverable and harvestable; however, it should be harvestable via OAI-PMH. + if (!discoverable && item.isHidden()) { + discoverable = true; } - doc.addField("item.deleted", deleted); - + boolean isDeleted = item.isWithdrawn() || (!discoverable) || (isEmbargoed && isPublic); + doc.addField("item.deleted", isDeleted); /* * An item that is embargoed will potentially not be harvested by @@ -307,9 +310,12 @@ public void reindexItem(Item item) { return; } try { + // Before reindexing delete item + deleteItemByQuery(item); + SolrClient server = solrServerResolver.getServer(); SolrInputDocument solrInput = index(item); - solrServerResolver.getServer().add(solrInput); - solrServerResolver.getServer().commit(); + server.add(solrInput); + server.commit(); cacheService.deleteAll(); itemCacheService.deleteAll(); } catch (IOException | XMLStreamException | SQLException | WritingXmlException | SolrServerException e) { @@ -326,8 +332,7 @@ public void reindexItem(Item item) { public void deleteItem(Item item) { try { - solrServerResolver.getServer().deleteByQuery("item.id:" + item.getID().toString()); - solrServerResolver.getServer().commit(); + deleteItemByQuery(item); cacheService.deleteAll(); itemCacheService.deleteAll(); } catch (SolrServerException | IOException e) { @@ -356,4 +361,13 @@ private boolean isTest() { return false; } + + /** + * Delete the item from Solr by the ID of the item + */ + private void deleteItemByQuery(Item item) throws SolrServerException, IOException { + SolrClient solrClient = solrServerResolver.getServer(); + solrClient.deleteByQuery("item.id:" + item.getID().toString()); + solrClient.commit(); + } } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ItemRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ItemRestRepositoryIT.java index 8f139a03f5d2..8f370af03d3c 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ItemRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ItemRestRepositoryIT.java @@ -40,8 +40,10 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; import javax.ws.rs.core.MediaType; @@ -63,6 +65,8 @@ import org.dspace.app.rest.repository.ItemRestRepository; import org.dspace.app.rest.test.AbstractControllerIntegrationTest; import org.dspace.app.rest.test.MetadataPatchSuite; +import org.dspace.authorize.ResourcePolicy; +import org.dspace.authorize.service.ResourcePolicyService; import org.dspace.builder.BitstreamBuilder; import org.dspace.builder.BundleBuilder; import org.dspace.builder.CollectionBuilder; @@ -87,6 +91,7 @@ import org.dspace.content.RelationshipType; import org.dspace.content.WorkspaceItem; import org.dspace.content.service.CollectionService; +import org.dspace.content.service.ItemService; import org.dspace.core.Constants; import org.dspace.eperson.EPerson; import org.dspace.eperson.Group; @@ -121,6 +126,11 @@ public class ItemRestRepositoryIT extends AbstractControllerIntegrationTest { @Autowired private ConfigurationService configurationService; + @Autowired + private ResourcePolicyService resourcePolicyService; + @Autowired + private ItemService itemService; + private Item publication1; private Item author1; private Item author2; @@ -2439,7 +2449,8 @@ private void runPatchMetadataTests(EPerson asUser, int expectedStatus) throws Ex context.restoreAuthSystemState(); String token = getAuthToken(asUser.getEmail(), password); - new MetadataPatchSuite().runWith(getClient(token), "/api/core/items/" + item.getID(), expectedStatus); + new MetadataPatchSuite("item-metadata-patch-suite.json").runWith(getClient(token), + "/api/core/items/" + item.getID(), expectedStatus); } /** @@ -2585,6 +2596,259 @@ public void updateTestEPersonWithoutPermissionForbidden() throws Exception { } } + @Test + public void createItemAsSubmitterRestPolicyCorrectCollectionIDTest() throws Exception { + context.turnOffAuthorisationSystem(); + Item item = null; + //disable file upload mandatory + configurationService.setProperty("webui.submit.upload.required", false); + try { + //** GIVEN ** + //1. A community with one collection. + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + + //2. create a normal user to use as submitter + EPerson submitter = EPersonBuilder.createEPerson(context) + .withEmail("submitter@example.com") + .withPassword("dspace") + .build(); + + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1") + .withSubmitterGroup(submitter).build(); + //Set property + configurationService.setProperty("allow.edit.metadata", col1.getID().toString()); + context.setCurrentUser(submitter); + + //4. a workspace item + WorkspaceItem wsitem = WorkspaceItemBuilder.createWorkspaceItem(context, col1) + .withTitle("Submission Item") + .withIssueDate("2017-10-17") + .grantLicense() + .build(); + context.restoreAuthSystemState(); + + // get the submitter auth token + String authToken = getAuthToken(submitter.getEmail(), "dspace"); + + // submit the workspaceitem to start the workflow - archived Item + getClient(authToken) + .perform(post(BASE_REST_SERVER_URL + "/api/workflow/workflowitems") + .content("/api/submission/workspaceitems/" + wsitem.getID()) + .contentType(textUriContentType)); + + // Find created item + Iterator it = itemService.findByCollection(context, col1); + item = it.hasNext() ? it.next() : null; + + // Find all resource policy + List list = resourcePolicyService.find(context, item); + boolean found = list.stream() + .anyMatch(resPol -> resPol.getAction() == Constants.WRITE && + resPol.getEPerson() != null && + resPol.getEPerson().getID().equals(submitter.getID())); + // submitter is a member of the submit group, collection ID is in property + // the resource policy was created + assert found; + } finally { + if (Objects.nonNull(item)) { + // remove the item if any + ItemBuilder.deleteItem(item.getID()); + } + } + } + + @Test + public void createItemAsSubmitterCollectionNameNotInConfigTest() throws Exception { + context.turnOffAuthorisationSystem(); + Item item = null; + //disable file upload mandatory + configurationService.setProperty("webui.submit.upload.required", false); + try { + //** GIVEN ** + //1. A community with one collection. + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + + //2. create a normal user to use as submitter + EPerson submitter = EPersonBuilder.createEPerson(context) + .withEmail("submitter@example.com") + .withPassword("dspace") + .build(); + + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1") + .withSubmitterGroup(submitter).build(); + context.setCurrentUser(submitter); + + //4. a workspace item + WorkspaceItem wsitem = WorkspaceItemBuilder.createWorkspaceItem(context, col1) + .withTitle("Submission Item") + .withIssueDate("2017-10-17") + .grantLicense() + .build(); + context.restoreAuthSystemState(); + + // get the submitter auth token + String authToken = getAuthToken(submitter.getEmail(), "dspace"); + + // submit the workspaceitem to start the workflow - archived Item + getClient(authToken) + .perform(post(BASE_REST_SERVER_URL + "/api/workflow/workflowitems") + .content("/api/submission/workspaceitems/" + wsitem.getID()) + .contentType(textUriContentType)); + + // Find created item + Iterator it = itemService.findByCollection(context, col1); + item = it.hasNext() ? it.next() : null; + + // Find all resource policy + List list = resourcePolicyService.find(context, item); + boolean found = list.stream() + .anyMatch(resPol -> resPol.getAction() == Constants.WRITE && + resPol.getEPerson() != null && + resPol.getEPerson().getID().equals(submitter.getID())); + // submission is member of submit group, collection name is not in property + // the resource policy was not created + assert !found; + } finally { + if (Objects.nonNull(item)) { + // remove the item if any + ItemBuilder.deleteItem(item.getID()); + } + } + } + + @Test + public void createItemAsAdminRestPolicyTest() throws Exception { + context.turnOffAuthorisationSystem(); + Item item = null; + //disable file upload mandatory + configurationService.setProperty("webui.submit.upload.required", false); + try { + //** GIVEN ** + //1. A community with one collection. + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + + //2. create a normal user to use as submitter + EPerson user = EPersonBuilder.createEPerson(context) + .withEmail("submitter@example.com") + .withPassword("dspace") + .build(); + + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1") + .withAdminGroup(user).build(); + //Set property + configurationService.setProperty("allow.edit.metadata", col1.getID().toString()); + + context.setCurrentUser(user); + + //4. a workspace item + WorkspaceItem wsitem = WorkspaceItemBuilder.createWorkspaceItem(context, col1) + .withTitle("Submission Item") + .withIssueDate("2017-10-17") + .grantLicense() + .build(); + context.restoreAuthSystemState(); + + // get the submitter auth token + String authToken = getAuthToken(user.getEmail(), "dspace"); + + // submit the workspaceitem to start the workflow - archived Item + getClient(authToken) + .perform(post(BASE_REST_SERVER_URL + "/api/workflow/workflowitems") + .content("/api/submission/workspaceitems/" + wsitem.getID()) + .contentType(textUriContentType)); + + // Find created item + Iterator it = itemService.findByCollection(context, col1); + item = it.hasNext() ? it.next() : null; + + // Find all resource policy + List list = resourcePolicyService.find(context, item); + boolean found = list.stream() + .anyMatch(resPol -> resPol.getAction() == Constants.WRITE && + resPol.getEPerson() != null && + resPol.getEPerson().getID().equals(user.getID())); + // submitter is not a member of the submit group + // the resource policy was not created + assert !found; + } finally { + if (Objects.nonNull(item)) { + // remove the item if any + ItemBuilder.deleteItem(item.getID()); + } + } + } + + @Test + public void createItemAsSubmitterRestPolicyCorrectCollectionNameTest() throws Exception { + context.turnOffAuthorisationSystem(); + Item item = null; + //Set property + String colName = "Collection 1"; + configurationService.setProperty("allow.edit.metadata", colName); + //disable file upload mandatory + configurationService.setProperty("webui.submit.upload.required", false); + try { + //** GIVEN ** + //1. A community with one collection. + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + + //2. create a normal user to use as submitter + EPerson submitter = EPersonBuilder.createEPerson(context) + .withEmail("submitter@example.com") + .withPassword("dspace") + .build(); + + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName(colName) + .withSubmitterGroup(submitter).build(); + + context.setCurrentUser(submitter); + + //4. a workspace item + WorkspaceItem wsitem = WorkspaceItemBuilder.createWorkspaceItem(context, col1) + .withTitle("Submission Item") + .withIssueDate("2017-10-17") + .grantLicense() + .build(); + context.restoreAuthSystemState(); + + // get the submitter auth token + String authToken = getAuthToken(submitter.getEmail(), "dspace"); + + // submit the workspaceitem to start the workflow - archived Item + getClient(authToken) + .perform(post(BASE_REST_SERVER_URL + "/api/workflow/workflowitems") + .content("/api/submission/workspaceitems/" + wsitem.getID()) + .contentType(textUriContentType)); + + // Find created item + Iterator it = itemService.findByCollection(context, col1); + item = it.hasNext() ? it.next() : null; + + // Find all resource policy + List list = resourcePolicyService.find(context, item); + boolean found = list.stream() + .anyMatch(resPol -> resPol.getAction() == Constants.WRITE && + resPol.getEPerson() != null && + resPol.getEPerson().getID().equals(submitter.getID())); + // submitter is a member of the submit group, collection name is in property + // the resource policy was created + assert found; + } finally { + if (Objects.nonNull(item)) { + // remove the item if any + ItemBuilder.deleteItem(item.getID()); + } + } + } + @Test public void createItemFromExternalSources() throws Exception { //We turn off the authorization system in order to create the structure as defined below diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataValueRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataValueRestRepositoryIT.java index 41f9112cbc09..2c27e1729854 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataValueRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataValueRestRepositoryIT.java @@ -86,7 +86,8 @@ public void findAll() throws Exception { // Get title metadata from the item MetadataValue titleMetadataValue = this.getTitleMetadataValue(); - getClient().perform(get("/api/core/metadatavalues") + String token = getAuthToken(eperson.getEmail(), password); + getClient(token).perform(get("/api/core/metadatavalues") .param("size", String.valueOf(100))) .andExpect(status().isOk()) .andExpect(content().contentType(contentType)) @@ -105,7 +106,8 @@ public void findOne() throws Exception { // Get title metadata from the item MetadataValue titleMetadataValue = this.getTitleMetadataValue(); - getClient().perform(get("/api/core/metadatavalues/" + titleMetadataValue.getID())) + String token = getAuthToken(eperson.getEmail(), password); + getClient(token).perform(get("/api/core/metadatavalues/" + titleMetadataValue.getID())) .andExpect(status().isOk()) .andExpect(content().contentType(contentType)) .andExpect(jsonPath("$", Matchers.is( @@ -160,7 +162,8 @@ public void findByValue_searchValue() throws Exception { String metadataQualifier = titleMetadataValue.getMetadataField().getQualifier(); String searchValue = titleMetadataValue.getValue(); - getClient().perform(get(SEARCH_BYVALUE_ENDPOINT) + String token = getAuthToken(eperson.getEmail(), password); + getClient(token).perform(get(SEARCH_BYVALUE_ENDPOINT) .param("schema", metadataSchema) .param("element", metadataElement) .param("qualifier", metadataQualifier) @@ -197,7 +200,8 @@ public void findByValue_searchValueWithStringAndNumber() throws Exception { String metadataQualifier = titleMetadataValue.getMetadataField().getQualifier(); String searchValue = titleMetadataValue.getValue(); - getClient().perform(get(SEARCH_BYVALUE_ENDPOINT) + String token = getAuthToken(eperson.getEmail(), password); + getClient(token).perform(get(SEARCH_BYVALUE_ENDPOINT) .param("schema", metadataSchema) .param("element", metadataElement) .param("qualifier", metadataQualifier) @@ -234,7 +238,8 @@ public void findByValue_searchValueIsNumber() throws Exception { String metadataQualifier = titleMetadataValue.getMetadataField().getQualifier(); String searchValue = titleMetadataValue.getValue(); - getClient().perform(get(SEARCH_BYVALUE_ENDPOINT) + String token = getAuthToken(eperson.getEmail(), password); + getClient(token).perform(get(SEARCH_BYVALUE_ENDPOINT) .param("schema", metadataSchema) .param("element", metadataElement) .param("qualifier", metadataQualifier) @@ -260,7 +265,8 @@ public void shouldReturnDistinctSuggestion() throws Exception { String metadataQualifier = titleMetadataValue.getMetadataField().getQualifier(); String searchValue = titleMetadataValue.getValue(); - getClient().perform(get(SEARCH_BYVALUE_ENDPOINT) + String token = getAuthToken(eperson.getEmail(), password); + getClient(token).perform(get(SEARCH_BYVALUE_ENDPOINT) .param("schema", metadataSchema) .param("element",metadataElement) .param("qualifier",metadataQualifier) @@ -312,7 +318,8 @@ public void shouldReturnOneSuggestionWhenInputHasMoreMetadataValues() throws Exc String metadataQualifier = titleMetadataValue.getMetadataField().getQualifier(); String searchValue = titleMetadataValue.getValue(); - getClient().perform(get(SEARCH_BYVALUE_ENDPOINT) + String token = getAuthToken(eperson.getEmail(), password); + getClient(token).perform(get(SEARCH_BYVALUE_ENDPOINT) .param("schema", metadataSchema) .param("element",metadataElement) .param("qualifier",metadataQualifier) diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ProvenanceExpectedMessages.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ProvenanceExpectedMessages.java new file mode 100644 index 000000000000..d95412a20bfd --- /dev/null +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ProvenanceExpectedMessages.java @@ -0,0 +1,50 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.rest; + +/** + * The ProvenanceExpectedMessages enum provides message templates for provenance messages. + * + * @author Michaela Paurikova (dspace at dataquest.sk) + */ +public enum ProvenanceExpectedMessages { + DISCOVERABLE("Item was made discoverable by first (admin) last (admin) (admin@email.com) on \nNo. " + + "of bitstreams: 0\nItem was in collections:\n"), + NON_DISCOVERABLE("Item was made non-discoverable by first (admin) last (admin) (admin@email.com) on " + + "\nNo. of bitstreams: 0\nItem was in collections:\n"), + MAPPED_COL("was mapped to collection"), + ADD_ITEM_MTD("Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on"), + REPLACE_ITEM_MTD("Item metadata (dc.title: Public item 1) was updated by first (admin) last (admin) " + + "(admin@email.com) on \nNo. of bitstreams: 0"), + REMOVE_ITEM_MTD("Item metadata (dc.title: Public item 1) was deleted by first (admin) last (admin) " + + "(admin@email.com) on \nNo. of bitstreams: 0"), + REMOVE_BITSTREAM_MTD("Item metadata (dc.description) was added by bitstream"), + REPLACE_BITSTREAM_MTD("metadata (dc.title: test) was updated by first (admin) last (admin) " + + "(admin@email.com) on \nNo. of bitstreams: 1\n"), + REMOVE_BITSTREAM("was deleted bitstream"), + ADD_BITSTREAM("Item was added bitstream to bundle"), + UPDATE_LICENSE("License (Test 1) was updated by first (admin) last (admin) (admin@email.com) " + + "on \nNo. of bitstreams: 1\n"), + ADD_LICENSE("License (empty) was added by first (admin) last (admin) (admin@email.com) on \nNo." + + " of bitstreams: 0"), + REMOVE_LICENSE("License (Test) was removed by first (admin) last (admin) (admin@email.com) on " + + "\nNo. of bitstreams: 1\n"), + MOVED_ITEM_COL("Item was moved from collection "); + + private final String template; + + // Constructor to initialize enum with the template string + ProvenanceExpectedMessages(String template) { + this.template = template; + } + + // Method to retrieve the template string + public String getTemplate() { + return template; + } +} diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ProvenanceServiceIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ProvenanceServiceIT.java new file mode 100644 index 000000000000..d9aa3768e250 --- /dev/null +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ProvenanceServiceIT.java @@ -0,0 +1,499 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.rest; + +import static java.nio.charset.Charset.defaultCharset; +import static org.apache.commons.io.IOUtils.toInputStream; +import static org.springframework.data.rest.webmvc.RestMediaTypes.TEXT_URI_LIST_VALUE; +import static org.springframework.http.MediaType.parseMediaType; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.io.IOException; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.ws.rs.core.MediaType; + +import org.dspace.app.rest.model.patch.AddOperation; +import org.dspace.app.rest.model.patch.Operation; +import org.dspace.app.rest.model.patch.RemoveOperation; +import org.dspace.app.rest.model.patch.ReplaceOperation; +import org.dspace.app.rest.test.AbstractControllerIntegrationTest; +import org.dspace.authorize.AuthorizeException; +import org.dspace.builder.BitstreamBuilder; +import org.dspace.builder.BundleBuilder; +import org.dspace.builder.ClarinLicenseBuilder; +import org.dspace.builder.ClarinLicenseLabelBuilder; +import org.dspace.builder.ClarinLicenseResourceMappingBuilder; +import org.dspace.builder.CollectionBuilder; +import org.dspace.builder.CommunityBuilder; +import org.dspace.builder.ItemBuilder; +import org.dspace.content.Bitstream; +import org.dspace.content.Bundle; +import org.dspace.content.Collection; +import org.dspace.content.DSpaceObject; +import org.dspace.content.Item; +import org.dspace.content.MetadataValue; +import org.dspace.content.clarin.ClarinLicense; +import org.dspace.content.clarin.ClarinLicenseLabel; +import org.dspace.content.clarin.ClarinLicenseResourceMapping; +import org.dspace.content.service.ItemService; +import org.dspace.content.service.clarin.ClarinLicenseLabelService; +import org.dspace.content.service.clarin.ClarinLicenseService; +import org.dspace.core.Constants; +import org.dspace.discovery.SearchServiceException; +import org.hamcrest.Matchers; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +public class ProvenanceServiceIT extends AbstractControllerIntegrationTest { + @Autowired + private ItemService itemService; + @Autowired + private ClarinLicenseLabelService clarinLicenseLabelService; + @Autowired + private ClarinLicenseService clarinLicenseService; + + private Collection collection; + private Item item; + + @Before + @Override + public void setUp() throws Exception { + super.setUp(); + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + collection = CollectionBuilder.createCollection(context, parentCommunity).build(); + item = ItemBuilder.createItem(context, collection) + .withTitle("Public item 1") + .build(); + context.restoreAuthSystemState(); + } + + @After + @Override + public void destroy() throws Exception { + context.turnOffAuthorisationSystem(); + // Delete community created in init() + try { + ItemBuilder.deleteItem(item.getID()); + CollectionBuilder.deleteCollection(collection.getID()); + CommunityBuilder.deleteCommunity(parentCommunity.getID()); + } catch (Exception e) { + // ignore + } + context.restoreAuthSystemState(); + + item = null; + collection = null; + parentCommunity = null; + super.destroy(); + } + + @Test + public void updateLicenseTest() throws Exception { + Bitstream bitstream = createBitstream(item, Constants.LICENSE_BUNDLE_NAME); + ClarinLicense clarinLicense1 = createClarinLicense("Test 1", "Test Def"); + ClarinLicenseResourceMapping mapping = createResourceMapping(clarinLicense1, bitstream); + ClarinLicense clarinLicense2 = createClarinLicense("Test 2", "Test Def"); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(put("/api/core/items/" + item.getID() + "/bundles") + .param("licenseID", clarinLicense2.getID().toString())) + .andExpect(status().isOk()); + objectCheck(itemService.find(context, item.getID()), ProvenanceExpectedMessages.UPDATE_LICENSE.getTemplate()); + + deleteBitstream(bitstream); + deleteClarinLicense(clarinLicense1); + deleteClarinLicense(clarinLicense2); + deleteResourceMapping(mapping.getID()); + } + + @Test + public void addLicenseTest() throws Exception { + ClarinLicense clarinLicense = createClarinLicense("Test", "Test Def"); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(put("/api/core/items/" + item.getID() + "/bundles") + .param("licenseID", clarinLicense.getID().toString())) + .andExpect(status().isOk()); + objectCheck(itemService.find(context, item.getID()), ProvenanceExpectedMessages.ADD_LICENSE.getTemplate()); + + deleteClarinLicense(clarinLicense); + } + + @Test + public void removeLicenseTest() throws Exception { + Bitstream bitstream = createBitstream(item, Constants.LICENSE_BUNDLE_NAME); + ClarinLicense clarinLicense = createClarinLicense("Test", "Test Def"); + ClarinLicenseResourceMapping mapping = createResourceMapping(clarinLicense, bitstream); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(put("/api/core/items/" + item.getID() + "/bundles") + .param("licenseID", "-1")) + .andExpect(status().isOk()); + objectCheck(itemService.find(context, item.getID()), ProvenanceExpectedMessages.REMOVE_LICENSE.getTemplate()); + + deleteBitstream(bitstream); + deleteClarinLicense(clarinLicense); + deleteResourceMapping(mapping.getID()); + } + + @Test + public void makeDiscoverableTest() throws Exception { + item.setDiscoverable(false); + String token = getAuthToken(admin.getEmail(), password); + List ops = new ArrayList<>(); + ReplaceOperation replaceOperation = new ReplaceOperation("/discoverable", true); + ops.add(replaceOperation); + String patchBody = getPatchContent(ops); + + getClient(token).perform(patch("/api/core/items/" + item.getID()) + .content(patchBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.uuid", Matchers.is(item.getID().toString()))) + .andExpect(jsonPath("$.discoverable", Matchers.is(true))); + + objectCheck(itemService.find(context, item.getID()), ProvenanceExpectedMessages.DISCOVERABLE.getTemplate()); + } + + @Test + public void makeNonDiscoverableTest() throws Exception { + item.setDiscoverable(true); + String token = getAuthToken(admin.getEmail(), password); + List ops = new ArrayList<>(); + ReplaceOperation replaceOperation = new ReplaceOperation("/discoverable", false); + ops.add(replaceOperation); + String patchBody = getPatchContent(ops); + + getClient(token).perform(patch("/api/core/items/" + item.getID()) + .content(patchBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + + objectCheck(itemService.find(context, item.getID()), ProvenanceExpectedMessages.NON_DISCOVERABLE.getTemplate()); + } + + @Test + public void addedToMappedCollTest() throws Exception { + Collection coll = createCollection(); + + String adminToken = getAuthToken(admin.getEmail(), password); + getClient(adminToken).perform( + post("/api/core/items/" + item.getID() + "/mappedCollections/") + .contentType(parseMediaType(TEXT_URI_LIST_VALUE)) + .content( + "https://localhost:8080/spring-rest/api/core/collections/" + coll.getID() + "\n" + ) + ); + objectCheck(itemService.find(context, item.getID()), ProvenanceExpectedMessages.MAPPED_COL.getTemplate()); + + deleteCollection(coll.getID()); + } + + @Test + public void addItemMetadataTest() throws Exception { + String adminToken = getAuthToken(admin.getEmail(), password); + List ops = new ArrayList<>(); + AddOperation addOperation = new AddOperation("/metadata/dc.title", "Test"); + ops.add(addOperation); + String patchBody = getPatchContent(ops); + getClient(adminToken).perform(patch("/api/core/items/" + item.getID()) + .content(patchBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + + objectCheck(itemService.find(context, item.getID()), ProvenanceExpectedMessages.ADD_ITEM_MTD.getTemplate()); + } + + @Test + public void replaceItemMetadataTest() throws Exception { + String adminToken = getAuthToken(admin.getEmail(), password); + int index = 0; + List ops = new ArrayList<>(); + ReplaceOperation replaceOperation = new ReplaceOperation("/metadata/dc.title/" + index, "Test"); + ops.add(replaceOperation); + String patchBody = getPatchContent(ops); + getClient(adminToken).perform(patch("/api/core/items/" + item.getID()) + .content(patchBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + + objectCheck(itemService.find(context, item.getID()), ProvenanceExpectedMessages.REPLACE_ITEM_MTD.getTemplate()); + } + + @Test + public void removeItemMetadataTest() throws Exception { + int index = 0; + String adminToken = getAuthToken(admin.getEmail(), password); + List ops = new ArrayList<>(); + RemoveOperation removeOperation = new RemoveOperation("/metadata/dc.title/" + index); + ops.add(removeOperation); + String patchBody = getPatchContent(ops); + getClient(adminToken).perform(patch("/api/core/items/" + item.getID()) + .content(patchBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + + objectCheck(itemService.find(context, item.getID()), ProvenanceExpectedMessages.REMOVE_ITEM_MTD.getTemplate()); + } + + @Test + public void removeBitstreamMetadataTest() throws Exception { + Bitstream bitstream = createBitstream(item, "test"); + + String adminToken = getAuthToken(admin.getEmail(), password); + List ops = new ArrayList<>(); + AddOperation addOperation = new AddOperation("/metadata/dc.description", "test"); + ops.add(addOperation); + String patchBody = getPatchContent(ops); + getClient(adminToken).perform(patch("/api/core/bitstreams/" + bitstream.getID()) + .content(patchBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + objectCheck(itemService.find(context, item.getID()), + ProvenanceExpectedMessages.REMOVE_BITSTREAM_MTD.getTemplate()); + + deleteBitstream(bitstream); + } + + @Test + public void addBitstreamMetadataTest() throws Exception { + Bitstream bitstream = createBitstream(item, "test"); + + String adminToken = getAuthToken(admin.getEmail(), password); + List ops = new ArrayList<>(); + AddOperation addOperation = new AddOperation("/metadata/dc.description", "test"); + ops.add(addOperation); + String patchBody = getPatchContent(ops); + getClient(adminToken).perform(patch("/api/core/bitstreams/" + bitstream.getID()) + .content(patchBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + objectCheck(itemService.find(context, item.getID()), + ProvenanceExpectedMessages.REMOVE_BITSTREAM_MTD.getTemplate()); + } + + @Test + public void updateMetadataBitstreamTest() throws Exception { + Bitstream bitstream = createBitstream(item, "test"); + bitstream.setName(context, "test"); + + String adminToken = getAuthToken(admin.getEmail(), password); + int index = 0; + List ops = new ArrayList<>(); + ReplaceOperation replaceOperation = new ReplaceOperation("/metadata/dc.title/" + index + "/value", "test 1"); + ops.add(replaceOperation); + String patchBody = getPatchContent(ops); + getClient(adminToken).perform(patch("/api/core/bitstreams/" + bitstream.getID()) + .content(patchBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + objectCheck(itemService.find(context, item.getID()), + ProvenanceExpectedMessages.REPLACE_BITSTREAM_MTD.getTemplate()); + + deleteBitstream(bitstream); + } + + @Test + public void removeBitstreamFromItemTest() throws Exception { + Bitstream bitstream = createBitstream(item, "test"); + + String adminToken = getAuthToken(admin.getEmail(), password); + List ops = new ArrayList<>(); + RemoveOperation removeOperation = new RemoveOperation("/bitstreams/" + bitstream.getID()); + ops.add(removeOperation); + String patchBody = getPatchContent(ops); + getClient(adminToken).perform(patch("/api/core/bitstreams") + .content(patchBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)); + objectCheck(itemService.find(context, item.getID()), ProvenanceExpectedMessages.REMOVE_BITSTREAM.getTemplate()); + + deleteBitstream(bitstream); + } + + @Test + public void addBitstreamToItemTest() throws Exception { + Bundle bundle = createBundle(item, "test"); + + String token = getAuthToken(admin.getEmail(), password); + String input = "Hello, World!"; + context.turnOffAuthorisationSystem(); + MockMultipartFile file = new MockMultipartFile("file", "hello.txt", + org.springframework.http.MediaType.TEXT_PLAIN_VALUE, + input.getBytes()); + context.restoreAuthSystemState(); + getClient(token) + .perform(MockMvcRequestBuilders.multipart("/api/core/bundles/" + bundle.getID() + "/bitstreams") + .file(file)) + .andExpect(status().isCreated()); + objectCheck(itemService.find(context, item.getID()), ProvenanceExpectedMessages.ADD_BITSTREAM.getTemplate()); + + deleteBundle(bundle.getID()); + } + + @Test + public void moveItemColTest() throws Exception { + Collection col = createCollection(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token) + .perform(put("/api/core/items/" + item.getID() + "/owningCollection/") + .contentType(parseMediaType(TEXT_URI_LIST_VALUE)) + .content( + "https://localhost:8080/spring-rest/api/core/collections/" + col.getID() + )) + .andExpect(status().isOk()); + objectCheck(itemService.find(context, item.getID()), ProvenanceExpectedMessages.MOVED_ITEM_COL.getTemplate()); + + deleteCollection(col.getID()); + } + + + private String provenanceMetadataModified(String metadata) { + // Regex to match the date pattern + String datePattern = "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z"; + Pattern pattern = Pattern.compile(datePattern); + Matcher matcher = pattern.matcher(metadata); + String rspModifiedProvenance = metadata; + while (matcher.find()) { + String dateString = matcher.group(0); + rspModifiedProvenance = rspModifiedProvenance.replaceAll(dateString, ""); + } + return rspModifiedProvenance; + } + + private void objectCheck(DSpaceObject obj, String expectedMessage) throws Exception { + List metadata = obj.getMetadata(); + boolean contain = false; + for (MetadataValue value : metadata) { + if (!Objects.equals(value.getMetadataField().toString(), "dc_description_provenance")) { + continue; + } + if (provenanceMetadataModified(value.getValue()).contains(expectedMessage)) { + contain = true; + break; + } + } + if (!contain) { + Assert.fail("Metadata provenance do not contain expected data: " + expectedMessage); + } + } + + private Bundle createBundle(Item item, String bundleName) throws SQLException, AuthorizeException { + context.turnOffAuthorisationSystem(); + Bundle bundle = BundleBuilder.createBundle(context, item).withName(bundleName).build(); + context.restoreAuthSystemState(); + return bundle; + } + + private Bitstream createBitstream(Item item, String bundleName) + throws SQLException, AuthorizeException, IOException { + context.turnOffAuthorisationSystem(); + Bundle bundle = createBundle(item, Objects.isNull(bundleName) ? "test" : bundleName); + Bitstream bitstream = BitstreamBuilder.createBitstream(context, bundle, + toInputStream("Test Content", defaultCharset())).build(); + context.restoreAuthSystemState(); + return bitstream; + } + + private void deleteBitstream(Bitstream bitstream) throws SQLException, IOException { + int size = bitstream.getBundles().size(); + for (int i = 0; i < size; i++) { + deleteBundle(bitstream.getBundles().get(i).getID()); + } + BitstreamBuilder.deleteBitstream(bitstream.getID()); + } + + + private void deleteBundle(UUID uuid) throws SQLException, IOException { + BundleBuilder.deleteBundle(uuid); + } + + private ClarinLicenseLabel createClarinLicenseLabel(String label, boolean extended, String title) + throws SQLException, AuthorizeException { + context.turnOffAuthorisationSystem(); + ClarinLicenseLabel clarinLicenseLabel = ClarinLicenseLabelBuilder.createClarinLicenseLabel(context).build(); + clarinLicenseLabel.setLabel(label); + clarinLicenseLabel.setExtended(extended); + clarinLicenseLabel.setTitle(title); + clarinLicenseLabelService.update(context, clarinLicenseLabel); + context.restoreAuthSystemState(); + return clarinLicenseLabel; + } + + private ClarinLicense createClarinLicense(String name, String definition) + throws SQLException, AuthorizeException { + context.turnOffAuthorisationSystem(); + ClarinLicense clarinLicense = ClarinLicenseBuilder.createClarinLicense(context).build(); + clarinLicense.setDefinition(definition); + clarinLicense.setName(name); + HashSet clarinLicenseLabels = new HashSet<>(); + ClarinLicenseLabel clarinLicenseLabel = createClarinLicenseLabel("lbl", false, "Test Title"); + clarinLicenseLabels.add(clarinLicenseLabel); + clarinLicense.setLicenseLabels(clarinLicenseLabels); + clarinLicenseService.update(context, clarinLicense); + context.restoreAuthSystemState(); + return clarinLicense; + } + + private void deleteClarinLicenseLable(Integer id) throws Exception { + ClarinLicenseLabelBuilder.deleteClarinLicenseLabel(id); + } + + private void deleteClarinLicense(ClarinLicense license) throws Exception { + int size = license.getLicenseLabels().size(); + for (int i = 0; i < size; i++) { + deleteClarinLicenseLable(license.getLicenseLabels().get(i).getID()); + } + ClarinLicenseBuilder.deleteClarinLicense(license.getID()); + } + + private Collection createCollection() { + context.turnOffAuthorisationSystem(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build(); + context.restoreAuthSystemState(); + return col; + } + + private void deleteCollection(UUID uuid) throws SearchServiceException, SQLException, IOException { + CollectionBuilder.deleteCollection(uuid); + } + + private ClarinLicenseResourceMapping createResourceMapping(ClarinLicense license, Bitstream bitstream) + throws SQLException, AuthorizeException { + context.turnOffAuthorisationSystem(); + ClarinLicenseResourceMapping resourceMapping = + ClarinLicenseResourceMappingBuilder.createClarinLicenseResourceMapping(context).build(); + context.restoreAuthSystemState(); + resourceMapping.setLicense(license); + resourceMapping.setBitstream(bitstream); + return resourceMapping; + } + + private void deleteResourceMapping(Integer id) throws Exception { + ClarinLicenseResourceMappingBuilder.delete(id); + } +} diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/test/MetadataPatchSuite.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/test/MetadataPatchSuite.java index 423a4cbe3513..97e1491d03c9 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/test/MetadataPatchSuite.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/test/MetadataPatchSuite.java @@ -11,10 +11,13 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.ws.rs.core.MediaType; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Assert; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -24,6 +27,7 @@ * Utility class for performing metadata patch tests sourced from a common json file (see constructor). */ public class MetadataPatchSuite { + static String PROVENANCE = "dc.description.provenance"; private final ObjectMapper objectMapper = new ObjectMapper(); private final JsonNode suite; @@ -36,6 +40,16 @@ public MetadataPatchSuite() throws Exception { suite = objectMapper.readTree(getClass().getResourceAsStream("metadata-patch-suite.json")); } + /** + * Initializes the suite by parsing the json file of tests. + * + * @param name name of resource + * @throws Exception if there is an error reading the file. + */ + public MetadataPatchSuite(String name) throws Exception { + suite = objectMapper.readTree(getClass().getResourceAsStream(name)); + } + /** * Runs all tests in the file using the given client and url, expecting the given status. * @@ -78,13 +92,32 @@ private void checkResponse(String verb, MockMvc client, MockHttpServletRequestBu .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) .andExpect(status().is(expectedStatus)); if (expectedStatus >= 200 && expectedStatus < 300) { - String responseBody = resultActions.andReturn().getResponse().getContentAsString(); - JsonNode responseJson = objectMapper.readTree(responseBody); - String responseMetadata = responseJson.get("metadata").toString(); - if (!responseMetadata.equals(expectedMetadata)) { - Assert.fail("Expected metadata in " + verb + " response: " + expectedMetadata - + "\nGot metadata in " + verb + " response: " + responseMetadata); - } + String responseBody = resultActions.andReturn().getResponse().getContentAsString(); + JsonNode responseJson = objectMapper.readTree(responseBody); + JsonNode responseMetadataJson = responseJson.get("metadata"); + if (responseMetadataJson.get(PROVENANCE) != null) { + // In the provenance metadata, there is a timestamp indicating when they were added. + // To ensure accurate comparison, remove that date. + String rspProvenance = responseMetadataJson.get(PROVENANCE).toString(); + // Regex to match the date pattern + String datePattern = "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z"; + Pattern pattern = Pattern.compile(datePattern); + Matcher matcher = pattern.matcher(rspProvenance); + String rspModifiedProvenance = rspProvenance; + while (matcher.find()) { + String dateString = matcher.group(0); + rspModifiedProvenance = rspModifiedProvenance.replaceAll(dateString, ""); + } + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode jsonNodePrv = objectMapper.readTree(rspModifiedProvenance); + // Replace the origin metadata with a value with the timestamp removed + ((ObjectNode) responseJson.get("metadata")).put(PROVENANCE, jsonNodePrv); + } + String responseMetadata = responseJson.get("metadata").toString(); + if (!responseMetadata.equals(expectedMetadata)) { + Assert.fail("Expected metadata in " + verb + " response: " + expectedMetadata + + "\nGot metadata in " + verb + " response: " + responseMetadata); + } } } } diff --git a/dspace-server-webapp/src/test/resources/org/dspace/app/rest/test/item-metadata-patch-suite.json b/dspace-server-webapp/src/test/resources/org/dspace/app/rest/test/item-metadata-patch-suite.json new file mode 100644 index 000000000000..fdbe61278546 --- /dev/null +++ b/dspace-server-webapp/src/test/resources/org/dspace/app/rest/test/item-metadata-patch-suite.json @@ -0,0 +1,219 @@ +{ + "tests": [ + { + "name": "clear metadata", + "patch": [ + { "op": "replace", + "path": "/metadata", + "value": {} + } + ], + "expect": {} + }, + { + "name": "add first title", + "patch": [ + { + "op": "add", + "path": "/metadata/dc.title", + "value": [ + { "value": "title 1" } + ] + } + ], + "expect": { + "dc.description.provenance" : [ + { "value" : "Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", "language" : "en", "authority" : null, "confidence" : -1, "place" : 0} + ], + "dc.title": [ + { "value": "title 1", "language": null, "authority": null, "confidence": -1, "place": 0} + ] + } + }, + { + "name": "add second title", + "patch": [ + { + "op": "add", + "path": "/metadata/dc.title/-", + "value": { "value": "最後のタイトル", "language": "ja_JP" } + } + ], + "expect": { + "dc.description.provenance":[ + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":0}, + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":1} + ], + "dc.title": [ + { "value": "title 1", "language": null, "authority": null, "confidence": -1,"place": 0 }, + { "value": "最後のタイトル", "language": "ja_JP", "authority": null, "confidence": -1 ,"place": 1} + ] + } + }, + { + "name": "insert zeroth title", + "patch": [ + { + "op": "add", + "path": "/metadata/dc.title/0", + "value": { + "value": "title 0" + } + } + ], + "expect": { + "dc.description.provenance":[ + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":0}, + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":1}, + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":2} + ], + "dc.title": [ + { "value": "title 0", "language": null, "authority": null, "confidence": -1 ,"place": 0 }, + { "value": "title 1", "language": null, "authority": null, "confidence": -1 ,"place": 1 }, + { "value": "最後のタイトル", "language": "ja_JP", "authority": null, "confidence": -1 ,"place": 2 } + ] + } + }, + { + "name": "move last title up one", + "patch": [ + { + "op": "move", + "from": "/metadata/dc.title/2", + "path": "/metadata/dc.title/1" + } + ], + "expect": { + "dc.description.provenance":[ + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":0}, + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":1}, + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":2} + ], + "dc.title": [ + { "value": "title 0", "language": null, "authority": null, "confidence": -1 ,"place": 0 }, + { "value": "最後のタイトル", "language": "ja_JP", "authority": null, "confidence": -1 ,"place": 1 }, + { "value": "title 1", "language": null, "authority": null, "confidence": -1 ,"place": 2 } + ] + } + }, + { + "name": "replace title 2 value and language in two operations", + "patch": [ + { + "op": "replace", + "path": "/metadata/dc.title/1/value", + "value": "title A" + }, + { + "op": "replace", + "path": "/metadata/dc.title/1/language", + "value": "en_US" + } + ], + "expect": { + "dc.description.provenance":[ + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":0}, + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":1}, + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":2} + ], + "dc.title": [ + { "value": "title 0", "language": null, "authority": null, "confidence": -1 ,"place": 0 }, + { "value": "title A", "language": "en_US", "authority": null, "confidence": -1 ,"place": 1 }, + { "value": "title 1", "language": null, "authority": null, "confidence": -1 ,"place": 2 } + ] + } + }, + { + "name": "copy title A to end of list", + "patch": [ + { + "op": "copy", + "from": "/metadata/dc.title/1", + "path": "/metadata/dc.title/-" + } + ], + "expect": { + "dc.description.provenance":[ + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":0}, + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":1}, + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":2} + ], + "dc.title": [ + { "value": "title 0", "language": null, "authority": null, "confidence": -1 ,"place": 0 }, + { "value": "title A", "language": "en_US", "authority": null, "confidence": -1 ,"place": 1 }, + { "value": "title 1", "language": null, "authority": null, "confidence": -1 ,"place": 2 }, + { "value": "title A", "language": "en_US", "authority": null, "confidence": -1 ,"place": 3 } + ] + } + }, + { + "name": "remove both title A copies", + "patch": [ + { + "op": "remove", + "path": "/metadata/dc.title/1" + }, + { + "op": "remove", + "path": "/metadata/dc.title/2" + } + ], + "expect": { + "dc.description.provenance":[ + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":0}, + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":1}, + {"value":"Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language":"en","authority":null,"confidence":-1,"place":2}, + {"value":"Item metadata (dc.title: title A) was deleted by first (admin) last (admin) (admin@email.com) on \nNo. of bitstreams: 0", + "language":"en","authority":null,"confidence":-1,"place":3}, + {"value":"Item metadata (dc.title: title A) was deleted by first (admin) last (admin) (admin@email.com) on \nNo. of bitstreams: 0", + "language":"en","authority":null,"confidence":-1,"place":4} + ], + "dc.title": [ + { "value": "title 0", "language": null, "authority": null, "confidence": -1 ,"place": 0 }, + { "value": "title 1", "language": null, "authority": null, "confidence": -1 ,"place": 1 } + ] + } + }, + { + "name": "remove all titles", + "patch": [ + { + "op": "remove", + "path": "/metadata/dc.title" + } + ], + "expect": { + "dc.description.provenance": [ + { + "value": "Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language": "en", "authority": null, "confidence": -1, "place": 0}, + {"value": "Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language": "en", "authority": null, "confidence": -1, "place": 1}, + {"value": "Item metadata (dc.title) was added by first (admin) last (admin) (admin@email.com) on ", + "language": "en", "authority": null, "confidence": -1, "place": 2}, + {"value": "Item metadata (dc.title: title A) was deleted by first (admin) last (admin) (admin@email.com) on \nNo. of bitstreams: 0", + "language": "en", "authority": null, "confidence": -1, "place": 3}, + {"value": "Item metadata (dc.title: title A) was deleted by first (admin) last (admin) (admin@email.com) on \nNo. of bitstreams: 0", + "language": "en", "authority": null, "confidence": -1, "place": 4} + ] + } + } + ] +} diff --git a/dspace/config/clarin-dspace.cfg b/dspace/config/clarin-dspace.cfg index f27d6830c213..53bb118d3136 100644 --- a/dspace/config/clarin-dspace.cfg +++ b/dspace/config/clarin-dspace.cfg @@ -154,7 +154,7 @@ matomo.auth.token = 26388b4164695d69e6ee6e2dd527b723 matomo.site.id = 1 matomo.tracker.bitstream.site_id = 1 matomo.tracker.oai.site_id = 1 -matomo.tracker.host.url = http://url:port/matomo.php +matomo.tracker.host.url = http://localhost.changeme/matomo.php matomo.custom.dimension.handle.id = 1 statistics.cache-server.uri = http://cache-server.none @@ -301,3 +301,8 @@ autocomplete.custom.separator.solr-dataProvider_ac = \\|\\|\\| autocomplete.custom.separator.solr-dctype_ac = \\|\\|\\| autocomplete.custom.separator.solr-author_ac = \\|\\|\\| autocomplete.custom.allowed = solr-author_ac,solr-publisher_ac,solr-dataProvider_ac,solr-dctype_ac,solr-subject_ac,solr-handle_title_ac,json_static-iso_langs.json + +##### METADATA EDIT ##### +#### name || id +### these collections allow submitters to edit metadata of their items +#allow.edit.metadata = \ No newline at end of file diff --git a/dspace/config/log4j2.xml b/dspace/config/log4j2.xml index a21cef9d6f3e..77bdbfe33ca9 100644 --- a/dspace/config/log4j2.xml +++ b/dspace/config/log4j2.xml @@ -29,9 +29,11 @@ > + "requestID" are not currently set in the ThreadContext. + Add this to the pattern to include the classId (hash) `%equals{%X{classID}}{}{unknown}`. + --> + pattern='%d %t %-5p %equals{%X{correlationID}}{}{unknown} %equals{%X{requestID}}{}{unknown} %c @ %m%n'/> yyyy-MM-dd diff --git a/dspace/config/modules/healthcheck.cfg b/dspace/config/modules/healthcheck.cfg index e45407abdfb1..69300b2f0029 100644 --- a/dspace/config/modules/healthcheck.cfg +++ b/dspace/config/modules/healthcheck.cfg @@ -5,10 +5,8 @@ # If you use the Pre-DSpace-3.0 embargo feature, you might want to # add 'Embargo items (Pre-3.0),' to the following list. healthcheck.checks = General Information,\ - Checksum,\ Item summary,\ - User summary,\ - Log Analyser Check + User summary plugin.named.org.dspace.health.Check = \ org.dspace.health.InfoCheck = General Information,\ @@ -18,5 +16,5 @@ plugin.named.org.dspace.health.Check = \ org.dspace.health.UserCheck = User summary,\ org.dspace.health.LogAnalyserCheck = Log Analyser Check -# report from the last N days (where dates are applicable) +# default value of the report from the last N days (where dates are applicable) healthcheck.last_n_days = 7 diff --git a/dspace/config/spring/api/core-services.xml b/dspace/config/spring/api/core-services.xml index 305d41f64ee5..f6ad5b1e5938 100644 --- a/dspace/config/spring/api/core-services.xml +++ b/dspace/config/spring/api/core-services.xml @@ -179,5 +179,7 @@ + + diff --git a/dspace/config/spring/api/scripts.xml b/dspace/config/spring/api/scripts.xml index d913a30b668e..ddf2c273232c 100644 --- a/dspace/config/spring/api/scripts.xml +++ b/dspace/config/spring/api/scripts.xml @@ -91,4 +91,9 @@ + + + + + diff --git a/dspace/config/spring/rest/scripts.xml b/dspace/config/spring/rest/scripts.xml index a8ef279383ae..d24612203f7e 100644 --- a/dspace/config/spring/rest/scripts.xml +++ b/dspace/config/spring/rest/scripts.xml @@ -74,4 +74,9 @@ + + + + + diff --git a/dspace/config/submission-forms.xml b/dspace/config/submission-forms.xml index 19aa68c31d78..9e2fd8c6e89c 100644 --- a/dspace/config/submission-forms.xml +++ b/dspace/config/submission-forms.xml @@ -58,7 +58,7 @@ dc type - true + false dropdown Type of the resource: "Corpus" refers to text, speech and multimodal corpora. @@ -3234,14 +3234,13 @@ - + - - + + \ No newline at end of file diff --git a/dspace/config/submission-forms_cs.xml b/dspace/config/submission-forms_cs.xml index c81cff0133b1..5a366a75aa8d 100644 --- a/dspace/config/submission-forms_cs.xml +++ b/dspace/config/submission-forms_cs.xml @@ -3188,12 +3188,12 @@ - + - +