diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 34539abc16..7f58a49f9e 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -31,6 +31,11 @@ jobs:
# We turn off 'latest' tag by default.
TAGS_FLAVOR: |
latest=false
+ # Architectures / Platforms for which we will build Docker images
+ # If this is a PR, we ONLY build for AMD64. For PRs we only do a sanity check test to ensure Docker builds work.
+ # If this is NOT a PR (e.g. a tag or merge commit), also build for ARM64. NOTE: The ARM64 build takes MUCH
+ # longer (around 45mins or so) which is why we only run it when pushing a new Docker image.
+ PLATFORMS: linux/amd64${{ github.event_name != 'pull_request' && ', linux/arm64' || '' }}
steps:
# https://github.com/actions/checkout
@@ -41,6 +46,10 @@ jobs:
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v1
+ # https://github.com/docker/setup-qemu-action
+ - name: Set up QEMU emulation to build for multiple architectures
+ uses: docker/setup-qemu-action@v2
+
# https://github.com/docker/login-action
- name: Login to DockerHub
# Only login if not a PR, as PRs only trigger a Docker build and not a push
@@ -70,6 +79,7 @@ jobs:
with:
context: .
file: ./Dockerfile.dependencies
+ platforms: ${{ env.PLATFORMS }}
# For pull requests, we run the Docker build (to ensure no PR changes break the build),
# but we ONLY do an image push to DockerHub if it's NOT a PR
push: ${{ github.event_name != 'pull_request' }}
@@ -95,6 +105,7 @@ jobs:
with:
context: .
file: ./Dockerfile
+ platforms: ${{ env.PLATFORMS }}
# For pull requests, we run the Docker build (to ensure no PR changes break the build),
# but we ONLY do an image push to DockerHub if it's NOT a PR
push: ${{ github.event_name != 'pull_request' }}
@@ -123,6 +134,7 @@ jobs:
with:
context: .
file: ./Dockerfile.test
+ platforms: ${{ env.PLATFORMS }}
# For pull requests, we run the Docker build (to ensure no PR changes break the build),
# but we ONLY do an image push to DockerHub if it's NOT a PR
push: ${{ github.event_name != 'pull_request' }}
@@ -148,9 +160,10 @@ jobs:
with:
context: .
file: ./Dockerfile.cli
+ platforms: ${{ env.PLATFORMS }}
# For pull requests, we run the Docker build (to ensure no PR changes break the build),
# but we ONLY do an image push to DockerHub if it's NOT a PR
push: ${{ github.event_name != 'pull_request' }}
# Use tags / labels provided by 'docker/metadata-action' above
tags: ${{ steps.meta_build_cli.outputs.tags }}
- labels: ${{ steps.meta_build_cli.outputs.labels }}
\ No newline at end of file
+ labels: ${{ steps.meta_build_cli.outputs.labels }}
diff --git a/README.md b/README.md
index 864a099c1d..37a46a70c9 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ Documentation for each release may be viewed online or downloaded via our [Docum
The latest DSpace Installation instructions are available at:
https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace
-Please be aware that, as a Java web application, DSpace requires a database (PostgreSQL or Oracle)
+Please be aware that, as a Java web application, DSpace requires a database (PostgreSQL)
and a servlet container (usually Tomcat) in order to function.
More information about these and all other prerequisites can be found in the Installation instructions above.
diff --git a/dspace-api/pom.xml b/dspace-api/pom.xml
index fc7349b379..b4cad7853f 100644
--- a/dspace-api/pom.xml
+++ b/dspace-api/pom.xml
@@ -336,7 +336,6 @@
-
org.apache.logging.log4jlog4j-api
@@ -361,6 +360,23 @@
ehcache${ehcache.version}
+
+
+ org.springframework.boot
+ spring-boot-starter-cache
+ ${spring-boot.version}
+
+
+ org.springframework.boot
+ spring-boot-starter-logging
+
+
+
+
+ javax.cache
+ cache-api
+ org.hibernatehibernate-jpamodelgen
@@ -862,6 +878,13 @@
mockserver-junit-rule5.11.2test
+
+
+
+ org.yaml
+ snakeyaml
+
+
diff --git a/dspace-api/src/main/java/org/dspace/access/status/AccessStatusHelper.java b/dspace-api/src/main/java/org/dspace/access/status/AccessStatusHelper.java
new file mode 100644
index 0000000000..1cacbf6aed
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/access/status/AccessStatusHelper.java
@@ -0,0 +1,30 @@
+/**
+ * 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.access.status;
+
+import java.sql.SQLException;
+import java.util.Date;
+
+import org.dspace.content.Item;
+import org.dspace.core.Context;
+
+/**
+ * Plugin interface for the access status calculation.
+ */
+public interface AccessStatusHelper {
+ /**
+ * Calculate the access status for the item.
+ *
+ * @param context the DSpace context
+ * @param item the item
+ * @return an access status value
+ * @throws SQLException An exception that provides information on a database access error or other errors.
+ */
+ public String getAccessStatusFromItem(Context context, Item item, Date threshold)
+ throws SQLException;
+}
diff --git a/dspace-api/src/main/java/org/dspace/access/status/AccessStatusServiceImpl.java b/dspace-api/src/main/java/org/dspace/access/status/AccessStatusServiceImpl.java
new file mode 100644
index 0000000000..544dc99cb4
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/access/status/AccessStatusServiceImpl.java
@@ -0,0 +1,66 @@
+/**
+ * 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.access.status;
+
+import java.sql.SQLException;
+import java.util.Date;
+
+import org.dspace.access.status.service.AccessStatusService;
+import org.dspace.content.Item;
+import org.dspace.core.Context;
+import org.dspace.core.service.PluginService;
+import org.dspace.services.ConfigurationService;
+import org.joda.time.LocalDate;
+import org.springframework.beans.factory.annotation.Autowired;
+
+/**
+ * Implementation for the access status calculation service.
+ */
+public class AccessStatusServiceImpl implements AccessStatusService {
+ // Plugin implementation, set from the DSpace configuration by init().
+ protected AccessStatusHelper helper = null;
+
+ protected Date forever_date = null;
+
+ @Autowired(required = true)
+ protected ConfigurationService configurationService;
+
+ @Autowired(required = true)
+ protected PluginService pluginService;
+
+ /**
+ * Initialize the bean (after dependency injection has already taken place).
+ * Ensures the configurationService is injected, so that we can get the plugin
+ * and the forever embargo date threshold from the configuration.
+ * Called by "init-method" in Spring configuration.
+ *
+ * @throws Exception on generic exception
+ */
+ public void init() throws Exception {
+ if (helper == null) {
+ helper = (AccessStatusHelper) pluginService.getSinglePlugin(AccessStatusHelper.class);
+ if (helper == null) {
+ throw new IllegalStateException("The AccessStatusHelper plugin was not defined in "
+ + "DSpace configuration.");
+ }
+
+ // Defines the embargo forever date threshold for the access status.
+ // Look at EmbargoService.FOREVER for some improvements?
+ int year = configurationService.getIntProperty("access.status.embargo.forever.year");
+ int month = configurationService.getIntProperty("access.status.embargo.forever.month");
+ int day = configurationService.getIntProperty("access.status.embargo.forever.day");
+
+ forever_date = new LocalDate(year, month, day).toDate();
+ }
+ }
+
+ @Override
+ public String getAccessStatus(Context context, Item item) throws SQLException {
+ return helper.getAccessStatusFromItem(context, item, forever_date);
+ }
+}
diff --git a/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java b/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java
new file mode 100644
index 0000000000..a67fa67af3
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java
@@ -0,0 +1,159 @@
+/**
+ * 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.access.status;
+
+import java.sql.SQLException;
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+
+import org.apache.commons.lang3.StringUtils;
+import org.dspace.authorize.ResourcePolicy;
+import org.dspace.authorize.factory.AuthorizeServiceFactory;
+import org.dspace.authorize.service.AuthorizeService;
+import org.dspace.authorize.service.ResourcePolicyService;
+import org.dspace.content.Bitstream;
+import org.dspace.content.Bundle;
+import org.dspace.content.DSpaceObject;
+import org.dspace.content.Item;
+import org.dspace.content.factory.ContentServiceFactory;
+import org.dspace.content.service.ItemService;
+import org.dspace.core.Constants;
+import org.dspace.core.Context;
+import org.dspace.eperson.Group;
+
+/**
+ * Default plugin implementation of the access status helper.
+ * The getAccessStatusFromItem method provides a simple logic to
+ * calculate the access status of an item based on the policies of
+ * the primary or the first bitstream in the original bundle.
+ * Users can override this method for enhanced functionality.
+ */
+public class DefaultAccessStatusHelper implements AccessStatusHelper {
+ public static final String EMBARGO = "embargo";
+ public static final String METADATA_ONLY = "metadata.only";
+ public static final String OPEN_ACCESS = "open.access";
+ public static final String RESTRICTED = "restricted";
+ public static final String UNKNOWN = "unknown";
+
+ protected ItemService itemService =
+ ContentServiceFactory.getInstance().getItemService();
+ protected ResourcePolicyService resourcePolicyService =
+ AuthorizeServiceFactory.getInstance().getResourcePolicyService();
+ protected AuthorizeService authorizeService =
+ AuthorizeServiceFactory.getInstance().getAuthorizeService();
+
+ public DefaultAccessStatusHelper() {
+ super();
+ }
+
+ /**
+ * Look at the item's policies to determine an access status value.
+ * It is also considering a date threshold for embargos and restrictions.
+ *
+ * If the item is null, simply returns the "unknown" value.
+ *
+ * @param context the DSpace context
+ * @param item the item to embargo
+ * @param threshold the embargo threshold date
+ * @return an access status value
+ */
+ @Override
+ public String getAccessStatusFromItem(Context context, Item item, Date threshold)
+ throws SQLException {
+ if (item == null) {
+ return UNKNOWN;
+ }
+ // Consider only the original bundles.
+ List bundles = item.getBundles(Constants.DEFAULT_BUNDLE_NAME);
+ // Check for primary bitstreams first.
+ Bitstream bitstream = bundles.stream()
+ .map(bundle -> bundle.getPrimaryBitstream())
+ .filter(Objects::nonNull)
+ .findFirst()
+ .orElse(null);
+ if (bitstream == null) {
+ // If there is no primary bitstream,
+ // take the first bitstream in the bundles.
+ bitstream = bundles.stream()
+ .map(bundle -> bundle.getBitstreams())
+ .flatMap(List::stream)
+ .findFirst()
+ .orElse(null);
+ }
+ return caculateAccessStatusForDso(context, bitstream, threshold);
+ }
+
+ /**
+ * Look at the DSpace object's policies to determine an access status value.
+ *
+ * If the object is null, returns the "metadata.only" value.
+ * If any policy attached to the object is valid for the anonymous group,
+ * returns the "open.access" value.
+ * Otherwise, if the policy start date is before the embargo threshold date,
+ * returns the "embargo" value.
+ * Every other cases return the "restricted" value.
+ *
+ * @param context the DSpace context
+ * @param dso the DSpace object
+ * @param threshold the embargo threshold date
+ * @return an access status value
+ */
+ private String caculateAccessStatusForDso(Context context, DSpaceObject dso, Date threshold)
+ throws SQLException {
+ if (dso == null) {
+ return METADATA_ONLY;
+ }
+ // Only consider read policies.
+ List policies = authorizeService
+ .getPoliciesActionFilter(context, dso, Constants.READ);
+ int openAccessCount = 0;
+ int embargoCount = 0;
+ int restrictedCount = 0;
+ int unknownCount = 0;
+ // Looks at all read policies.
+ for (ResourcePolicy policy : policies) {
+ boolean isValid = resourcePolicyService.isDateValid(policy);
+ Group group = policy.getGroup();
+ // The group must not be null here. However,
+ // if it is, consider this as an unexpected case.
+ if (group == null) {
+ unknownCount++;
+ } else if (StringUtils.equals(group.getName(), Group.ANONYMOUS)) {
+ // Only calculate the status for the anonymous group.
+ if (isValid) {
+ // If the policy is valid, the anonymous group have access
+ // to the bitstream.
+ openAccessCount++;
+ } else {
+ Date startDate = policy.getStartDate();
+ if (startDate != null && !startDate.before(threshold)) {
+ // If the policy start date have a value and if this value
+ // is equal or superior to the configured forever date, the
+ // access status is also restricted.
+ restrictedCount++;
+ } else {
+ // If the current date is not between the policy start date
+ // and end date, the access status is embargo.
+ embargoCount++;
+ }
+ }
+ }
+ }
+ if (openAccessCount > 0) {
+ return OPEN_ACCESS;
+ }
+ if (embargoCount > 0 && restrictedCount == 0) {
+ return EMBARGO;
+ }
+ if (unknownCount > 0) {
+ return UNKNOWN;
+ }
+ return RESTRICTED;
+ }
+}
diff --git a/dspace-api/src/main/java/org/dspace/access/status/factory/AccessStatusServiceFactory.java b/dspace-api/src/main/java/org/dspace/access/status/factory/AccessStatusServiceFactory.java
new file mode 100644
index 0000000000..77d8f6b448
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/access/status/factory/AccessStatusServiceFactory.java
@@ -0,0 +1,25 @@
+/**
+ * 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.access.status.factory;
+
+import org.dspace.access.status.service.AccessStatusService;
+import org.dspace.services.factory.DSpaceServicesFactory;
+
+/**
+ * Abstract factory to get services for the access status package,
+ * use AccessStatusServiceFactory.getInstance() to retrieve an implementation.
+ */
+public abstract class AccessStatusServiceFactory {
+
+ public abstract AccessStatusService getAccessStatusService();
+
+ public static AccessStatusServiceFactory getInstance() {
+ return DSpaceServicesFactory.getInstance().getServiceManager()
+ .getServiceByName("accessStatusServiceFactory", AccessStatusServiceFactory.class);
+ }
+}
diff --git a/dspace-api/src/main/java/org/dspace/access/status/factory/AccessStatusServiceFactoryImpl.java b/dspace-api/src/main/java/org/dspace/access/status/factory/AccessStatusServiceFactoryImpl.java
new file mode 100644
index 0000000000..fe3848cb2b
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/access/status/factory/AccessStatusServiceFactoryImpl.java
@@ -0,0 +1,26 @@
+/**
+ * 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.access.status.factory;
+
+import org.dspace.access.status.service.AccessStatusService;
+import org.springframework.beans.factory.annotation.Autowired;
+
+/**
+ * Factory implementation to get services for the access status package,
+ * use AccessStatusServiceFactory.getInstance() to retrieve an implementation.
+ */
+public class AccessStatusServiceFactoryImpl extends AccessStatusServiceFactory {
+
+ @Autowired(required = true)
+ private AccessStatusService accessStatusService;
+
+ @Override
+ public AccessStatusService getAccessStatusService() {
+ return accessStatusService;
+ }
+}
diff --git a/dspace-api/src/main/java/org/dspace/access/status/package-info.java b/dspace-api/src/main/java/org/dspace/access/status/package-info.java
new file mode 100644
index 0000000000..2c0ed22cd4
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/access/status/package-info.java
@@ -0,0 +1,30 @@
+/**
+ * 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/
+ */
+/**
+ *
+ * Access status allows the users to view the bitstreams availability before
+ * browsing into the item itself.
+ *
+ *
+ * The access status is calculated through a pluggable class:
+ * {@link org.dspace.access.status.AccessStatusHelper}.
+ * The {@link org.dspace.access.status.AccessStatusServiceImpl}
+ * must be configured to specify this class, as well as a forever embargo date
+ * threshold year, month and day.
+ *
+ *
+ * See {@link org.dspace.access.status.DefaultAccessStatusHelper} for a simple calculation
+ * based on the primary or the first bitstream of the original bundle. You can
+ * supply your own class to implement more complex access statuses.
+ *
+ *
+ * For now, the access status is calculated when the item is shown in a list.
+ *
+ */
+
+package org.dspace.access.status;
diff --git a/dspace-api/src/main/java/org/dspace/access/status/service/AccessStatusService.java b/dspace-api/src/main/java/org/dspace/access/status/service/AccessStatusService.java
new file mode 100644
index 0000000000..43de5e3c47
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/access/status/service/AccessStatusService.java
@@ -0,0 +1,46 @@
+/**
+ * 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.access.status.service;
+
+import java.sql.SQLException;
+
+import org.dspace.content.Item;
+import org.dspace.core.Context;
+
+/**
+ * Public interface to the access status subsystem.
+ *
+ * Configuration properties: (with examples)
+ * {@code
+ * # values for the forever embargo date threshold
+ * # This threshold date is used in the default access status helper to dermine if an item is
+ * # restricted or embargoed based on the start date of the primary (or first) file policies.
+ * # In this case, if the policy start date is inferior to the threshold date, the status will
+ * # be embargo, else it will be restricted.
+ * # You might want to change this threshold based on your needs. For example: some databases
+ * # doesn't accept a date superior to 31 december 9999.
+ * access.status.embargo.forever.year = 10000
+ * access.status.embargo.forever.month = 1
+ * access.status.embargo.forever.day = 1
+ * # implementation of access status helper plugin - replace with local implementation if applicable
+ * # This default access status helper provides an item status based on the policies of the primary
+ * # bitstream (or first bitstream in the original bundles if no primary file is specified).
+ * plugin.single.org.dspace.access.status.AccessStatusHelper = org.dspace.access.status.DefaultAccessStatusHelper
+ * }
+ */
+public interface AccessStatusService {
+
+ /**
+ * Calculate the access status for an Item while considering the forever embargo date threshold.
+ *
+ * @param context the DSpace context
+ * @param item the item
+ * @throws SQLException An exception that provides information on a database access error or other errors.
+ */
+ public String getAccessStatus(Context context, Item item) throws SQLException;
+}
diff --git a/dspace-api/src/main/java/org/dspace/app/exception/ResourceAlreadyExistsException.java b/dspace-api/src/main/java/org/dspace/app/exception/ResourceAlreadyExistsException.java
new file mode 100644
index 0000000000..8291af87fc
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/app/exception/ResourceAlreadyExistsException.java
@@ -0,0 +1,32 @@
+/**
+ * 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.exception;
+
+/**
+ * This class provides an exception to be used when trying to save a resource
+ * that already exists.
+ *
+ * @author Luca Giamminonni (luca.giamminonni at 4science.it)
+ *
+ */
+public class ResourceAlreadyExistsException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Create a ResourceAlreadyExistsException with a message and the already
+ * existing resource.
+ *
+ * @param message the error message
+ */
+ public ResourceAlreadyExistsException(String message) {
+ super(message);
+ }
+
+
+}
diff --git a/dspace-api/src/main/java/org/dspace/app/profile/ResearcherProfile.java b/dspace-api/src/main/java/org/dspace/app/profile/ResearcherProfile.java
new file mode 100644
index 0000000000..584b505044
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/app/profile/ResearcherProfile.java
@@ -0,0 +1,83 @@
+/**
+ * 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.profile;
+
+import static org.dspace.core.Constants.READ;
+import static org.dspace.eperson.Group.ANONYMOUS;
+
+import java.util.Optional;
+import java.util.UUID;
+import java.util.stream.Stream;
+
+import org.dspace.content.Item;
+import org.dspace.content.MetadataValue;
+import org.dspace.util.UUIDUtils;
+import org.springframework.util.Assert;
+
+/**
+ * Object representing a Researcher Profile.
+ *
+ * @author Luca Giamminonni (luca.giamminonni at 4science.it)
+ *
+ */
+public class ResearcherProfile {
+
+ private final Item item;
+
+ private final MetadataValue dspaceObjectOwner;
+
+ /**
+ * Create a new ResearcherProfile object from the given item.
+ *
+ * @param item the profile item
+ * @throws IllegalArgumentException if the given item has not a dspace.object.owner
+ * metadata with a valid authority
+ */
+ public ResearcherProfile(Item item) {
+ Assert.notNull(item, "A researcher profile requires an item");
+ this.item = item;
+ this.dspaceObjectOwner = getDspaceObjectOwnerMetadata(item);
+ }
+
+ public UUID getId() {
+ return UUIDUtils.fromString(dspaceObjectOwner.getAuthority());
+ }
+
+ /**
+ * A profile is considered visible if accessible by anonymous users. This method
+ * returns true if the given item has a READ policy related to ANONYMOUS group,
+ * false otherwise.
+ */
+ public boolean isVisible() {
+ return item.getResourcePolicies().stream()
+ .filter(policy -> policy.getGroup() != null)
+ .anyMatch(policy -> READ == policy.getAction() && ANONYMOUS.equals(policy.getGroup().getName()));
+ }
+
+ public Item getItem() {
+ return item;
+ }
+
+ private MetadataValue getDspaceObjectOwnerMetadata(Item item) {
+ return getMetadataValue(item, "dspace.object.owner")
+ .filter(metadata -> UUIDUtils.fromString(metadata.getAuthority()) != null)
+ .orElseThrow(
+ () -> new IllegalArgumentException("A profile item must have a valid dspace.object.owner metadata")
+ );
+ }
+
+ private Optional getMetadataValue(Item item, String metadataField) {
+ return getMetadataValues(item, metadataField).findFirst();
+ }
+
+ private Stream getMetadataValues(Item item, String metadataField) {
+ return item.getMetadata().stream()
+ .filter(metadata -> metadataField.equals(metadata.getMetadataField().toString('.')));
+ }
+
+}
diff --git a/dspace-api/src/main/java/org/dspace/app/profile/ResearcherProfileServiceImpl.java b/dspace-api/src/main/java/org/dspace/app/profile/ResearcherProfileServiceImpl.java
new file mode 100644
index 0000000000..22977463f7
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/app/profile/ResearcherProfileServiceImpl.java
@@ -0,0 +1,367 @@
+/**
+ * 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.profile;
+
+import static java.util.Optional.empty;
+import static java.util.Optional.of;
+import static java.util.Optional.ofNullable;
+import static org.dspace.content.authority.Choices.CF_ACCEPTED;
+import static org.dspace.core.Constants.READ;
+import static org.dspace.core.Constants.WRITE;
+import static org.dspace.eperson.Group.ANONYMOUS;
+
+import java.io.IOException;
+import java.net.URI;
+import java.sql.SQLException;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang.StringUtils;
+import org.dspace.app.exception.ResourceAlreadyExistsException;
+import org.dspace.app.profile.service.ResearcherProfileService;
+import org.dspace.authorize.AuthorizeException;
+import org.dspace.authorize.service.AuthorizeService;
+import org.dspace.content.Collection;
+import org.dspace.content.Item;
+import org.dspace.content.MetadataValue;
+import org.dspace.content.WorkspaceItem;
+import org.dspace.content.service.CollectionService;
+import org.dspace.content.service.InstallItemService;
+import org.dspace.content.service.ItemService;
+import org.dspace.content.service.WorkspaceItemService;
+import org.dspace.core.Context;
+import org.dspace.discovery.DiscoverQuery;
+import org.dspace.discovery.DiscoverResult;
+import org.dspace.discovery.IndexableObject;
+import org.dspace.discovery.SearchService;
+import org.dspace.discovery.SearchServiceException;
+import org.dspace.discovery.indexobject.IndexableCollection;
+import org.dspace.eperson.EPerson;
+import org.dspace.eperson.Group;
+import org.dspace.eperson.service.GroupService;
+import org.dspace.services.ConfigurationService;
+import org.dspace.util.UUIDUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.util.Assert;
+
+/**
+ * Implementation of {@link ResearcherProfileService}.
+ *
+ * @author Luca Giamminonni (luca.giamminonni at 4science.it)
+ *
+ */
+public class ResearcherProfileServiceImpl implements ResearcherProfileService {
+
+ private static Logger log = LoggerFactory.getLogger(ResearcherProfileServiceImpl.class);
+
+ @Autowired
+ private ItemService itemService;
+
+ @Autowired
+ private WorkspaceItemService workspaceItemService;
+
+ @Autowired
+ private InstallItemService installItemService;
+
+ @Autowired
+ private ConfigurationService configurationService;
+
+ @Autowired
+ private CollectionService collectionService;
+
+ @Autowired
+ private SearchService searchService;
+
+ @Autowired
+ private GroupService groupService;
+
+ @Autowired
+ private AuthorizeService authorizeService;
+
+ @Override
+ public ResearcherProfile findById(Context context, UUID id) throws SQLException, AuthorizeException {
+ Assert.notNull(id, "An id must be provided to find a researcher profile");
+
+ Item profileItem = findResearcherProfileItemById(context, id);
+ if (profileItem == null) {
+ return null;
+ }
+
+ return new ResearcherProfile(profileItem);
+ }
+
+ @Override
+ public ResearcherProfile createAndReturn(Context context, EPerson ePerson)
+ throws AuthorizeException, SQLException, SearchServiceException {
+
+ Item profileItem = findResearcherProfileItemById(context, ePerson.getID());
+ if (profileItem != null) {
+ throw new ResourceAlreadyExistsException("A profile is already linked to the provided User");
+ }
+
+ Collection collection = findProfileCollection(context)
+ .orElseThrow(() -> new IllegalStateException("No collection found for researcher profiles"));
+
+ context.turnOffAuthorisationSystem();
+ try {
+
+ Item item = createProfileItem(context, ePerson, collection);
+ return new ResearcherProfile(item);
+
+ } finally {
+ context.restoreAuthSystemState();
+ }
+
+ }
+
+ @Override
+ public void deleteById(Context context, UUID id) throws SQLException, AuthorizeException {
+ Assert.notNull(id, "An id must be provided to find a researcher profile");
+
+ Item profileItem = findResearcherProfileItemById(context, id);
+ if (profileItem == null) {
+ return;
+ }
+
+ if (isHardDeleteEnabled()) {
+ deleteItem(context, profileItem);
+ } else {
+ removeOwnerMetadata(context, profileItem);
+ }
+
+ }
+
+ @Override
+ public void changeVisibility(Context context, ResearcherProfile profile, boolean visible)
+ throws AuthorizeException, SQLException {
+
+ if (profile.isVisible() == visible) {
+ return;
+ }
+
+ Item item = profile.getItem();
+ Group anonymous = groupService.findByName(context, ANONYMOUS);
+
+ if (visible) {
+ authorizeService.addPolicy(context, item, READ, anonymous);
+ } else {
+ authorizeService.removeGroupPolicies(context, item, anonymous);
+ }
+
+ }
+
+ @Override
+ public ResearcherProfile claim(Context context, EPerson ePerson, URI uri)
+ throws SQLException, AuthorizeException, SearchServiceException {
+
+ Item profileItem = findResearcherProfileItemById(context, ePerson.getID());
+ if (profileItem != null) {
+ throw new ResourceAlreadyExistsException("A profile is already linked to the provided User");
+ }
+
+ Item item = findItemByURI(context, uri)
+ .orElseThrow(() -> new IllegalArgumentException("No item found by URI " + uri));
+
+ if (!item.isArchived() || item.isWithdrawn()) {
+ throw new IllegalArgumentException(
+ "Only archived items can be claimed to create a researcher profile. Item ID: " + item.getID());
+ }
+
+ if (!hasProfileType(item)) {
+ throw new IllegalArgumentException("The provided item has not a profile type. Item ID: " + item.getID());
+ }
+
+ if (haveDifferentEmail(item, ePerson)) {
+ throw new IllegalArgumentException("The provided item is not claimable because it has a different email "
+ + "than the given user's email. Item ID: " + item.getID());
+ }
+
+ String existingOwner = itemService.getMetadataFirstValue(item, "dspace", "object", "owner", Item.ANY);
+
+ if (StringUtils.isNotBlank(existingOwner)) {
+ throw new IllegalArgumentException("Item with provided uri has already an owner - ID: " + existingOwner);
+ }
+
+ context.turnOffAuthorisationSystem();
+ itemService.addMetadata(context, item, "dspace", "object", "owner", null,
+ ePerson.getName(), ePerson.getID().toString(), CF_ACCEPTED);
+ context.restoreAuthSystemState();
+
+ return new ResearcherProfile(item);
+ }
+
+ @Override
+ public boolean hasProfileType(Item item) {
+ String profileType = getProfileType();
+ if (StringUtils.isBlank(profileType)) {
+ return false;
+ }
+ return profileType.equals(itemService.getEntityTypeLabel(item));
+ }
+
+ @Override
+ public String getProfileType() {
+ return configurationService.getProperty("researcher-profile.entity-type", "Person");
+ }
+
+ private Optional findItemByURI(final Context context, final URI uri) throws SQLException {
+ String path = uri.getPath();
+ UUID uuid = UUIDUtils.fromString(path.substring(path.lastIndexOf("/") + 1));
+ return ofNullable(itemService.find(context, uuid));
+ }
+
+ /**
+ * Search for an profile item owned by an eperson with the given id.
+ */
+ private Item findResearcherProfileItemById(Context context, UUID id) throws SQLException, AuthorizeException {
+
+ String profileType = getProfileType();
+
+ Iterator items = itemService.findByAuthorityValue(context, "dspace", "object", "owner", id.toString());
+ while (items.hasNext()) {
+ Item item = items.next();
+ String entityType = itemService.getEntityTypeLabel(item);
+ if (profileType.equals(entityType)) {
+ return item;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns a Profile collection based on a configuration or searching for a
+ * collection of researcher profile type.
+ */
+ private Optional findProfileCollection(Context context) throws SQLException, SearchServiceException {
+ return findConfiguredProfileCollection(context)
+ .or(() -> findFirstCollectionByProfileEntityType(context));
+ }
+
+ /**
+ * Create a new profile item for the given ePerson in the provided collection.
+ */
+ private Item createProfileItem(Context context, EPerson ePerson, Collection collection)
+ throws AuthorizeException, SQLException {
+
+ String id = ePerson.getID().toString();
+ String fullName = ePerson.getFullName();
+
+ WorkspaceItem workspaceItem = workspaceItemService.create(context, collection, true);
+ Item item = workspaceItem.getItem();
+ itemService.addMetadata(context, item, "dc", "title", null, null, fullName);
+ itemService.addMetadata(context, item, "person", "email", null, null, ePerson.getEmail());
+ itemService.addMetadata(context, item, "dspace", "object", "owner", null, fullName, id, CF_ACCEPTED);
+
+ item = installItemService.installItem(context, workspaceItem);
+
+ if (isNewProfileNotVisibleByDefault()) {
+ Group anonymous = groupService.findByName(context, ANONYMOUS);
+ authorizeService.removeGroupPolicies(context, item, anonymous);
+ }
+
+ authorizeService.addPolicy(context, item, READ, ePerson);
+ authorizeService.addPolicy(context, item, WRITE, ePerson);
+
+ return reloadItem(context, item);
+ }
+
+ private Optional findConfiguredProfileCollection(Context context) throws SQLException {
+ UUID uuid = UUIDUtils.fromString(configurationService.getProperty("researcher-profile.collection.uuid"));
+ if (uuid == null) {
+ return Optional.empty();
+ }
+
+ Collection collection = collectionService.find(context, uuid);
+ if (collection == null) {
+ return Optional.empty();
+ }
+
+ if (isNotProfileCollection(collection)) {
+ log.warn("The configured researcher-profile.collection.uuid "
+ + "has an invalid entity type, expected " + getProfileType());
+ return Optional.empty();
+ }
+
+ return of(collection);
+ }
+
+ @SuppressWarnings("rawtypes")
+ private Optional findFirstCollectionByProfileEntityType(Context context) {
+
+ String profileType = getProfileType();
+
+ DiscoverQuery discoverQuery = new DiscoverQuery();
+ discoverQuery.setDSpaceObjectFilter(IndexableCollection.TYPE);
+ discoverQuery.addFilterQueries("dspace.entity.type:" + profileType);
+
+ DiscoverResult discoverResult = search(context, discoverQuery);
+ List indexableObjects = discoverResult.getIndexableObjects();
+
+ if (CollectionUtils.isEmpty(indexableObjects)) {
+ return empty();
+ }
+
+ return ofNullable((Collection) indexableObjects.get(0).getIndexedObject());
+ }
+
+ private boolean isHardDeleteEnabled() {
+ return configurationService.getBooleanProperty("researcher-profile.hard-delete.enabled");
+ }
+
+ private boolean isNewProfileNotVisibleByDefault() {
+ return !configurationService.getBooleanProperty("researcher-profile.set-new-profile-visible");
+ }
+
+ private boolean isNotProfileCollection(Collection collection) {
+ String entityType = collectionService.getMetadataFirstValue(collection, "dspace", "entity", "type", Item.ANY);
+ return entityType == null || !entityType.equals(getProfileType());
+ }
+
+ private boolean haveDifferentEmail(Item item, EPerson currentUser) {
+ return itemService.getMetadataByMetadataString(item, "person.email").stream()
+ .map(MetadataValue::getValue)
+ .filter(StringUtils::isNotBlank)
+ .noneMatch(email -> email.equalsIgnoreCase(currentUser.getEmail()));
+ }
+
+ private void removeOwnerMetadata(Context context, Item profileItem) throws SQLException {
+ List metadata = itemService.getMetadata(profileItem, "dspace", "object", "owner", Item.ANY);
+ itemService.removeMetadataValues(context, profileItem, metadata);
+ }
+
+ private Item reloadItem(Context context, Item item) throws SQLException {
+ context.uncacheEntity(item);
+ return context.reloadEntity(item);
+ }
+
+ private void deleteItem(Context context, Item profileItem) throws SQLException, AuthorizeException {
+ try {
+ context.turnOffAuthorisationSystem();
+ itemService.delete(context, profileItem);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ } finally {
+ context.restoreAuthSystemState();
+ }
+ }
+
+ private DiscoverResult search(Context context, DiscoverQuery discoverQuery) {
+ try {
+ return searchService.search(context, discoverQuery);
+ } catch (SearchServiceException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+}
diff --git a/dspace-api/src/main/java/org/dspace/app/profile/service/ResearcherProfileService.java b/dspace-api/src/main/java/org/dspace/app/profile/service/ResearcherProfileService.java
new file mode 100644
index 0000000000..359f91761a
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/app/profile/service/ResearcherProfileService.java
@@ -0,0 +1,112 @@
+/**
+ * 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.profile.service;
+
+import java.net.URI;
+import java.sql.SQLException;
+import java.util.UUID;
+
+import org.dspace.app.profile.ResearcherProfile;
+import org.dspace.authorize.AuthorizeException;
+import org.dspace.content.Item;
+import org.dspace.core.Context;
+import org.dspace.discovery.SearchServiceException;
+import org.dspace.eperson.EPerson;
+
+/**
+ * Service interface class for the {@link ResearcherProfile} object. The
+ * implementation of this class is responsible for all business logic calls for
+ * the {@link ResearcherProfile} object.
+ *
+ * @author Luca Giamminonni (luca.giamminonni at 4science.it)
+ *
+ */
+public interface ResearcherProfileService {
+
+ /**
+ * Find the ResearcherProfile by UUID.
+ *
+ * @param context the relevant DSpace Context.
+ * @param id the ResearcherProfile id
+ * @return the found ResearcherProfile
+ * @throws SQLException
+ * @throws AuthorizeException
+ */
+ public ResearcherProfile findById(Context context, UUID id) throws SQLException, AuthorizeException;
+
+ /**
+ * Create a new researcher profile for the given ePerson.
+ *
+ * @param context the relevant DSpace Context.
+ * @param ePerson the ePerson
+ * @return the created profile
+ * @throws SQLException
+ * @throws AuthorizeException
+ * @throws SearchServiceException
+ */
+ public ResearcherProfile createAndReturn(Context context, EPerson ePerson)
+ throws AuthorizeException, SQLException, SearchServiceException;
+
+ /**
+ * Delete the profile with the given id. Based on the
+ * researcher-profile.hard-delete.enabled configuration, this method deletes the
+ * related item or removes the association between the researcher profile and
+ * eperson related to the input uuid.
+ *
+ * @param context the relevant DSpace Context.
+ * @param id the researcher profile id
+ * @throws AuthorizeException
+ * @throws SQLException
+ */
+ public void deleteById(Context context, UUID id) throws SQLException, AuthorizeException;
+
+ /**
+ * Changes the visibility of the given profile using the given new visible
+ * value. The visiblity controls whether the Profile is Anonymous READ or not.
+ *
+ * @param context the relevant DSpace Context.
+ * @param profile the researcher profile to update
+ * @param visible the visible value to set. If true the profile will
+ * be visible to all users.
+ * @throws SQLException
+ * @throws AuthorizeException
+ */
+ public void changeVisibility(Context context, ResearcherProfile profile, boolean visible)
+ throws AuthorizeException, SQLException;
+
+ /**
+ * Claims and links an eperson to an existing DSpaceObject
+ * @param context the relevant DSpace Context.
+ * @param ePerson the ePerson
+ * @param uri uri of existing Item to be linked to the
+ * eperson
+ * @return the created profile
+ * @throws IllegalArgumentException if the given uri is not related to an
+ * archived item or if the item cannot be
+ * claimed
+ */
+ ResearcherProfile claim(Context context, EPerson ePerson, URI uri)
+ throws SQLException, AuthorizeException, SearchServiceException;
+
+ /**
+ * Check if the given item has an entity type compatible with that of the
+ * researcher profile. If the given item does not have an entity type, the check
+ * returns false.
+ *
+ * @param item the item to check
+ * @return the check result
+ */
+ boolean hasProfileType(Item item);
+
+ /**
+ * Returns the profile entity type, if any.
+ *
+ * @return the profile type
+ */
+ String getProfileType();
+}
diff --git a/dspace-api/src/main/java/org/dspace/app/sherpa/SHERPAService.java b/dspace-api/src/main/java/org/dspace/app/sherpa/SHERPAService.java
index 87198fe172..ead725e842 100644
--- a/dspace-api/src/main/java/org/dspace/app/sherpa/SHERPAService.java
+++ b/dspace-api/src/main/java/org/dspace/app/sherpa/SHERPAService.java
@@ -31,6 +31,7 @@ import org.dspace.app.sherpa.v2.SHERPAResponse;
import org.dspace.app.sherpa.v2.SHERPAUtils;
import org.dspace.services.ConfigurationService;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.cache.annotation.Cacheable;
/**
* SHERPAService is responsible for making the HTTP call to the SHERPA v2 API
@@ -43,6 +44,7 @@ import org.springframework.beans.factory.annotation.Autowired;
* @author Kim Shepherd
*/
public class SHERPAService {
+
private CloseableHttpClient client = null;
private int maxNumberOfTries;
@@ -91,6 +93,7 @@ public class SHERPAService {
* @param query ISSN string to pass in an "issn equals" API query
* @return SHERPAResponse containing an error or journal policies
*/
+ @Cacheable(key = "#query", cacheNames = "sherpa.searchByJournalISSN")
public SHERPAResponse searchByJournalISSN(String query) {
return performRequest("publication", "issn", "equals", query, 0, 1);
}
@@ -413,4 +416,5 @@ public class SHERPAService {
public void setTimeout(int timeout) {
this.timeout = timeout;
}
-}
+
+}
\ No newline at end of file
diff --git a/dspace-api/src/main/java/org/dspace/app/sherpa/cache/SherpaCacheEvictService.java b/dspace-api/src/main/java/org/dspace/app/sherpa/cache/SherpaCacheEvictService.java
new file mode 100644
index 0000000000..94ecfb5e21
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/app/sherpa/cache/SherpaCacheEvictService.java
@@ -0,0 +1,71 @@
+/**
+ * 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.sherpa.cache;
+
+import java.util.Objects;
+import java.util.Set;
+
+import org.dspace.app.sherpa.submit.SHERPASubmitService;
+import org.dspace.content.Item;
+import org.dspace.core.Context;
+import org.springframework.cache.CacheManager;
+
+/**
+ * This service is responsible to deal with the SherpaService cache.
+ *
+ * @author Mykhaylo Boychuk (mykhaylo.boychuk at 4science.com)
+ */
+public class SherpaCacheEvictService {
+
+ // The cache that is managed by this service.
+ static final String CACHE_NAME = "sherpa.searchByJournalISSN";
+
+ private CacheManager cacheManager;
+
+ private SHERPASubmitService sherpaSubmitService;
+
+ /**
+ * Remove immediately from the cache all the response that are related to a specific item
+ * extracting the ISSNs from the item
+ *
+ * @param context The DSpace context
+ * @param item an Item
+ */
+ public void evictCacheValues(Context context, Item item) {
+ Set ISSNs = sherpaSubmitService.getISSNs(context, item);
+ for (String issn : ISSNs) {
+ Objects.requireNonNull(cacheManager.getCache(CACHE_NAME)).evictIfPresent(issn);
+ }
+ }
+
+ /**
+ * Invalidate immediately the Sherpa cache
+ */
+ public void evictAllCacheValues() {
+ Objects.requireNonNull(cacheManager.getCache(CACHE_NAME)).invalidate();
+ }
+
+ /**
+ * Set the reference to the cacheManager
+ *
+ * @param cacheManager
+ */
+ public void setCacheManager(CacheManager cacheManager) {
+ this.cacheManager = cacheManager;
+ }
+
+ /**
+ * Set the reference to the SherpaSubmitService
+ *
+ * @param sherpaSubmitService
+ */
+ public void setSherpaSubmitService(SHERPASubmitService sherpaSubmitService) {
+ this.sherpaSubmitService = sherpaSubmitService;
+ }
+
+}
\ No newline at end of file
diff --git a/dspace-api/src/main/java/org/dspace/app/sherpa/cache/SherpaCacheLogger.java b/dspace-api/src/main/java/org/dspace/app/sherpa/cache/SherpaCacheLogger.java
new file mode 100644
index 0000000000..e84fb7775a
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/app/sherpa/cache/SherpaCacheLogger.java
@@ -0,0 +1,34 @@
+/**
+ * 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.sherpa.cache;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.ehcache.event.CacheEvent;
+import org.ehcache.event.CacheEventListener;
+
+/**
+ * This is a EHCache listner responsible for logging sherpa cache events. It is
+ * bound to the sherpa cache via the dspace/config/ehcache.xml file. We need a
+ * dedicated Logger for each cache as the CacheEvent doesn't include details
+ * about where the event occur
+ *
+ * @author Mykhaylo Boychuk (mykhaylo.boychuk at 4science.com)
+ *
+ */
+public class SherpaCacheLogger implements CacheEventListener