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/util/DCInput.java b/dspace-api/src/main/java/org/dspace/app/util/DCInput.java index 32fd5d634d..f9fc97ec09 100644 --- a/dspace-api/src/main/java/org/dspace/app/util/DCInput.java +++ b/dspace-api/src/main/java/org/dspace/app/util/DCInput.java @@ -561,6 +561,15 @@ public class DCInput { return true; } + /** + * Get the type bind list for use in determining whether + * to display this field in angular dynamic form building + * @return list of bound types + */ + public List getTypeBindList() { + return typeBind; + } + /** * Verify whether the current field contains an entity relationship * This also implies a relationship type is defined for this field diff --git a/dspace-api/src/test/data/dspaceFolder/config/submission-forms.xml b/dspace-api/src/test/data/dspaceFolder/config/submission-forms.xml index 67946788b5..2642a5f897 100644 --- a/dspace-api/src/test/data/dspaceFolder/config/submission-forms.xml +++ b/dspace-api/src/test/data/dspaceFolder/config/submission-forms.xml @@ -140,6 +140,7 @@ ispartofseries true + Technical Report series Enter the series and number assigned to this item by your community. diff --git a/dspace-api/src/test/java/org/dspace/access/status/AccessStatusServiceTest.java b/dspace-api/src/test/java/org/dspace/access/status/AccessStatusServiceTest.java new file mode 100644 index 0000000000..87127f9cf8 --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/access/status/AccessStatusServiceTest.java @@ -0,0 +1,126 @@ +/** + * 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 static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.fail; + +import java.sql.SQLException; + +import org.apache.logging.log4j.Logger; +import org.dspace.AbstractUnitTest; +import org.dspace.access.status.factory.AccessStatusServiceFactory; +import org.dspace.access.status.service.AccessStatusService; +import org.dspace.authorize.AuthorizeException; +import org.dspace.content.Collection; +import org.dspace.content.Community; +import org.dspace.content.Item; +import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.CollectionService; +import org.dspace.content.service.CommunityService; +import org.dspace.content.service.InstallItemService; +import org.dspace.content.service.ItemService; +import org.dspace.content.service.WorkspaceItemService; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Unit Tests for access status service + */ +public class AccessStatusServiceTest extends AbstractUnitTest { + + private static final Logger log = org.apache.logging.log4j.LogManager.getLogger(AccessStatusServiceTest.class); + + private Collection collection; + private Community owningCommunity; + private Item item; + + protected CommunityService communityService = + ContentServiceFactory.getInstance().getCommunityService(); + protected CollectionService collectionService = + ContentServiceFactory.getInstance().getCollectionService(); + protected ItemService itemService = + ContentServiceFactory.getInstance().getItemService(); + protected WorkspaceItemService workspaceItemService = + ContentServiceFactory.getInstance().getWorkspaceItemService(); + protected InstallItemService installItemService = + ContentServiceFactory.getInstance().getInstallItemService(); + protected AccessStatusService accessStatusService = + AccessStatusServiceFactory.getInstance().getAccessStatusService(); + + /** + * This method will be run before every test as per @Before. It will + * initialize resources required for the tests. + * + * Other methods can be annotated with @Before here or in subclasses + * but no execution order is guaranteed + */ + @Before + @Override + public void init() { + super.init(); + try { + context.turnOffAuthorisationSystem(); + owningCommunity = communityService.create(null, context); + collection = collectionService.create(context, owningCommunity); + item = installItemService.installItem(context, + workspaceItemService.create(context, collection, true)); + context.restoreAuthSystemState(); + } catch (AuthorizeException ex) { + log.error("Authorization Error in init", ex); + fail("Authorization Error in init: " + ex.getMessage()); + } catch (SQLException ex) { + log.error("SQL Error in init", ex); + fail("SQL Error in init: " + ex.getMessage()); + } + } + + /** + * This method will be run after every test as per @After. It will + * clean resources initialized by the @Before methods. + * + * Other methods can be annotated with @After here or in subclasses + * but no execution order is guaranteed + */ + @After + @Override + public void destroy() { + context.turnOffAuthorisationSystem(); + try { + itemService.delete(context, item); + } catch (Exception e) { + // ignore + } + try { + collectionService.delete(context, collection); + } catch (Exception e) { + // ignore + } + try { + communityService.delete(context, owningCommunity); + } catch (Exception e) { + // ignore + } + context.restoreAuthSystemState(); + item = null; + collection = null; + owningCommunity = null; + try { + super.destroy(); + } catch (Exception e) { + // ignore + } + } + + @Test + public void testGetAccessStatus() throws Exception { + String status = accessStatusService.getAccessStatus(context, item); + assertNotEquals("testGetAccessStatus 0", status, DefaultAccessStatusHelper.UNKNOWN); + } +} diff --git a/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java b/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java new file mode 100644 index 0000000000..a41e985deb --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java @@ -0,0 +1,423 @@ +/** + * 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 static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.fail; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dspace.AbstractUnitTest; +import org.dspace.authorize.AuthorizeException; +import org.dspace.authorize.ResourcePolicy; +import org.dspace.authorize.factory.AuthorizeServiceFactory; +import org.dspace.authorize.service.ResourcePolicyService; +import org.dspace.content.Bitstream; +import org.dspace.content.Bundle; +import org.dspace.content.Collection; +import org.dspace.content.Community; +import org.dspace.content.Item; +import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.BitstreamService; +import org.dspace.content.service.BundleService; +import org.dspace.content.service.CollectionService; +import org.dspace.content.service.CommunityService; +import org.dspace.content.service.InstallItemService; +import org.dspace.content.service.ItemService; +import org.dspace.content.service.WorkspaceItemService; +import org.dspace.core.Constants; +import org.dspace.eperson.Group; +import org.dspace.eperson.factory.EPersonServiceFactory; +import org.dspace.eperson.service.GroupService; +import org.joda.time.LocalDate; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class DefaultAccessStatusHelperTest extends AbstractUnitTest { + + private static final Logger log = LogManager.getLogger(DefaultAccessStatusHelperTest.class); + + private Collection collection; + private Community owningCommunity; + private Item itemWithoutBundle; + private Item itemWithoutBitstream; + private Item itemWithBitstream; + private Item itemWithEmbargo; + private Item itemWithDateRestriction; + private Item itemWithGroupRestriction; + private Item itemWithoutPolicy; + private Item itemWithoutPrimaryBitstream; + private Item itemWithPrimaryAndMultipleBitstreams; + private Item itemWithoutPrimaryAndMultipleBitstreams; + private DefaultAccessStatusHelper helper; + private Date threshold; + + protected CommunityService communityService = + ContentServiceFactory.getInstance().getCommunityService(); + protected CollectionService collectionService = + ContentServiceFactory.getInstance().getCollectionService(); + protected ItemService itemService = + ContentServiceFactory.getInstance().getItemService(); + protected WorkspaceItemService workspaceItemService = + ContentServiceFactory.getInstance().getWorkspaceItemService(); + protected InstallItemService installItemService = + ContentServiceFactory.getInstance().getInstallItemService(); + protected BundleService bundleService = + ContentServiceFactory.getInstance().getBundleService(); + protected BitstreamService bitstreamService = + ContentServiceFactory.getInstance().getBitstreamService(); + protected ResourcePolicyService resourcePolicyService = + AuthorizeServiceFactory.getInstance().getResourcePolicyService(); + protected GroupService groupService = + EPersonServiceFactory.getInstance().getGroupService(); + + /** + * This method will be run before every test as per @Before. It will + * initialize resources required for the tests. + * + * Other methods can be annotated with @Before here or in subclasses + * but no execution order is guaranteed + */ + @Before + @Override + public void init() { + super.init(); + try { + context.turnOffAuthorisationSystem(); + owningCommunity = communityService.create(null, context); + collection = collectionService.create(context, owningCommunity); + itemWithoutBundle = installItemService.installItem(context, + workspaceItemService.create(context, collection, true)); + itemWithoutBitstream = installItemService.installItem(context, + workspaceItemService.create(context, collection, true)); + itemWithBitstream = installItemService.installItem(context, + workspaceItemService.create(context, collection, true)); + itemWithEmbargo = installItemService.installItem(context, + workspaceItemService.create(context, collection, true)); + itemWithDateRestriction = installItemService.installItem(context, + workspaceItemService.create(context, collection, true)); + itemWithGroupRestriction = installItemService.installItem(context, + workspaceItemService.create(context, collection, true)); + itemWithoutPolicy = installItemService.installItem(context, + workspaceItemService.create(context, collection, true)); + itemWithoutPrimaryBitstream = installItemService.installItem(context, + workspaceItemService.create(context, collection, true)); + itemWithPrimaryAndMultipleBitstreams = installItemService.installItem(context, + workspaceItemService.create(context, collection, true)); + itemWithoutPrimaryAndMultipleBitstreams = installItemService.installItem(context, + workspaceItemService.create(context, collection, true)); + context.restoreAuthSystemState(); + } catch (AuthorizeException ex) { + log.error("Authorization Error in init", ex); + fail("Authorization Error in init: " + ex.getMessage()); + } catch (SQLException ex) { + log.error("SQL Error in init", ex); + fail("SQL Error in init: " + ex.getMessage()); + } + helper = new DefaultAccessStatusHelper(); + threshold = new LocalDate(10000, 1, 1).toDate(); + } + + /** + * This method will be run after every test as per @After. It will + * clean resources initialized by the @Before methods. + * + * Other methods can be annotated with @After here or in subclasses + * but no execution order is guaranteed + */ + @After + @Override + public void destroy() { + context.turnOffAuthorisationSystem(); + try { + itemService.delete(context, itemWithoutBundle); + itemService.delete(context, itemWithoutBitstream); + itemService.delete(context, itemWithBitstream); + itemService.delete(context, itemWithEmbargo); + itemService.delete(context, itemWithDateRestriction); + itemService.delete(context, itemWithGroupRestriction); + itemService.delete(context, itemWithoutPolicy); + itemService.delete(context, itemWithoutPrimaryBitstream); + itemService.delete(context, itemWithPrimaryAndMultipleBitstreams); + itemService.delete(context, itemWithoutPrimaryAndMultipleBitstreams); + } catch (Exception e) { + // ignore + } + try { + collectionService.delete(context, collection); + } catch (Exception e) { + // ignore + } + try { + communityService.delete(context, owningCommunity); + } catch (Exception e) { + // ignore + } + context.restoreAuthSystemState(); + itemWithoutBundle = null; + itemWithoutBitstream = null; + itemWithBitstream = null; + itemWithEmbargo = null; + itemWithDateRestriction = null; + itemWithGroupRestriction = null; + itemWithoutPolicy = null; + itemWithoutPrimaryBitstream = null; + itemWithPrimaryAndMultipleBitstreams = null; + itemWithoutPrimaryAndMultipleBitstreams = null; + collection = null; + owningCommunity = null; + helper = null; + threshold = null; + communityService = null; + collectionService = null; + itemService = null; + workspaceItemService = null; + installItemService = null; + bundleService = null; + bitstreamService = null; + resourcePolicyService = null; + groupService = null; + try { + super.destroy(); + } catch (Exception e) { + // ignore + } + } + + /** + * Test for a null item + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithNullItem() throws Exception { + String status = helper.getAccessStatusFromItem(context, null, threshold); + assertThat("testWithNullItem 0", status, equalTo(DefaultAccessStatusHelper.UNKNOWN)); + } + + /** + * Test for an item with no bundle + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithoutBundle() throws Exception { + String status = helper.getAccessStatusFromItem(context, itemWithoutBundle, threshold); + assertThat("testWithoutBundle 0", status, equalTo(DefaultAccessStatusHelper.METADATA_ONLY)); + } + + /** + * Test for an item with no bitstream + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithoutBitstream() throws Exception { + context.turnOffAuthorisationSystem(); + bundleService.create(context, itemWithoutBitstream, Constants.CONTENT_BUNDLE_NAME); + context.restoreAuthSystemState(); + String status = helper.getAccessStatusFromItem(context, itemWithoutBitstream, threshold); + assertThat("testWithoutBitstream 0", status, equalTo(DefaultAccessStatusHelper.METADATA_ONLY)); + } + + /** + * Test for an item with a basic bitstream (open access) + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithBitstream() throws Exception { + context.turnOffAuthorisationSystem(); + Bundle bundle = bundleService.create(context, itemWithBitstream, Constants.CONTENT_BUNDLE_NAME); + Bitstream bitstream = bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + bitstream.setName(context, "primary"); + bundle.setPrimaryBitstreamID(bitstream); + context.restoreAuthSystemState(); + String status = helper.getAccessStatusFromItem(context, itemWithBitstream, threshold); + assertThat("testWithBitstream 0", status, equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + } + + /** + * Test for an item with an embargo + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithEmbargo() throws Exception { + context.turnOffAuthorisationSystem(); + Bundle bundle = bundleService.create(context, itemWithEmbargo, Constants.CONTENT_BUNDLE_NAME); + Bitstream bitstream = bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + bitstream.setName(context, "primary"); + bundle.setPrimaryBitstreamID(bitstream); + List policies = new ArrayList<>(); + ResourcePolicy policy = resourcePolicyService.create(context); + policy.setRpName("Embargo"); + Group group = groupService.findByName(context, Group.ANONYMOUS); + policy.setGroup(group); + policy.setAction(Constants.READ); + policy.setStartDate(new LocalDate(9999, 12, 31).toDate()); + policies.add(policy); + authorizeService.removeAllPolicies(context, bitstream); + authorizeService.addPolicies(context, policies, bitstream); + context.restoreAuthSystemState(); + String status = helper.getAccessStatusFromItem(context, itemWithEmbargo, threshold); + assertThat("testWithEmbargo 0", status, equalTo(DefaultAccessStatusHelper.EMBARGO)); + } + + /** + * Test for an item with an anonymous date restriction + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithDateRestriction() throws Exception { + context.turnOffAuthorisationSystem(); + Bundle bundle = bundleService.create(context, itemWithDateRestriction, Constants.CONTENT_BUNDLE_NAME); + Bitstream bitstream = bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + bitstream.setName(context, "primary"); + bundle.setPrimaryBitstreamID(bitstream); + List policies = new ArrayList<>(); + ResourcePolicy policy = resourcePolicyService.create(context); + policy.setRpName("Restriction"); + Group group = groupService.findByName(context, Group.ANONYMOUS); + policy.setGroup(group); + policy.setAction(Constants.READ); + policy.setStartDate(new LocalDate(10000, 1, 1).toDate()); + policies.add(policy); + authorizeService.removeAllPolicies(context, bitstream); + authorizeService.addPolicies(context, policies, bitstream); + context.restoreAuthSystemState(); + String status = helper.getAccessStatusFromItem(context, itemWithDateRestriction, threshold); + assertThat("testWithDateRestriction 0", status, equalTo(DefaultAccessStatusHelper.RESTRICTED)); + } + + /** + * Test for an item with a group restriction + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithGroupRestriction() throws Exception { + context.turnOffAuthorisationSystem(); + Bundle bundle = bundleService.create(context, itemWithGroupRestriction, Constants.CONTENT_BUNDLE_NAME); + Bitstream bitstream = bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + bitstream.setName(context, "primary"); + bundle.setPrimaryBitstreamID(bitstream); + List policies = new ArrayList<>(); + ResourcePolicy policy = resourcePolicyService.create(context); + policy.setRpName("Restriction"); + Group group = groupService.findByName(context, Group.ADMIN); + policy.setGroup(group); + policy.setAction(Constants.READ); + policies.add(policy); + authorizeService.removeAllPolicies(context, bitstream); + authorizeService.addPolicies(context, policies, bitstream); + context.restoreAuthSystemState(); + String status = helper.getAccessStatusFromItem(context, itemWithGroupRestriction, threshold); + assertThat("testWithGroupRestriction 0", status, equalTo(DefaultAccessStatusHelper.RESTRICTED)); + } + + /** + * Test for an item with no policy + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithoutPolicy() throws Exception { + context.turnOffAuthorisationSystem(); + Bundle bundle = bundleService.create(context, itemWithoutPolicy, Constants.CONTENT_BUNDLE_NAME); + Bitstream bitstream = bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + bitstream.setName(context, "primary"); + bundle.setPrimaryBitstreamID(bitstream); + authorizeService.removeAllPolicies(context, bitstream); + context.restoreAuthSystemState(); + String status = helper.getAccessStatusFromItem(context, itemWithoutPolicy, threshold); + assertThat("testWithoutPolicy 0", status, equalTo(DefaultAccessStatusHelper.RESTRICTED)); + } + + /** + * Test for an item with no primary bitstream + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithoutPrimaryBitstream() throws Exception { + context.turnOffAuthorisationSystem(); + Bundle bundle = bundleService.create(context, itemWithoutPrimaryBitstream, Constants.CONTENT_BUNDLE_NAME); + Bitstream bitstream = bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + bitstream.setName(context, "first"); + context.restoreAuthSystemState(); + String status = helper.getAccessStatusFromItem(context, itemWithoutPrimaryBitstream, threshold); + assertThat("testWithoutPrimaryBitstream 0", status, equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + } + + /** + * Test for an item with an open access bitstream + * and another primary bitstream on embargo + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithPrimaryAndMultipleBitstreams() throws Exception { + context.turnOffAuthorisationSystem(); + Bundle bundle = bundleService.create(context, itemWithPrimaryAndMultipleBitstreams, + Constants.CONTENT_BUNDLE_NAME); + bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + Bitstream primaryBitstream = bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + bundle.setPrimaryBitstreamID(primaryBitstream); + List policies = new ArrayList<>(); + ResourcePolicy policy = resourcePolicyService.create(context); + policy.setRpName("Embargo"); + Group group = groupService.findByName(context, Group.ANONYMOUS); + policy.setGroup(group); + policy.setAction(Constants.READ); + policy.setStartDate(new LocalDate(9999, 12, 31).toDate()); + policies.add(policy); + authorizeService.removeAllPolicies(context, primaryBitstream); + authorizeService.addPolicies(context, policies, primaryBitstream); + context.restoreAuthSystemState(); + String status = helper.getAccessStatusFromItem(context, itemWithPrimaryAndMultipleBitstreams, threshold); + assertThat("testWithPrimaryAndMultipleBitstreams 0", status, equalTo(DefaultAccessStatusHelper.EMBARGO)); + } + + /** + * Test for an item with an open access bitstream + * and another bitstream on embargo + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithNoPrimaryAndMultipleBitstreams() throws Exception { + context.turnOffAuthorisationSystem(); + Bundle bundle = bundleService.create(context, itemWithoutPrimaryAndMultipleBitstreams, + Constants.CONTENT_BUNDLE_NAME); + bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + Bitstream anotherBitstream = bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + List policies = new ArrayList<>(); + ResourcePolicy policy = resourcePolicyService.create(context); + policy.setRpName("Embargo"); + Group group = groupService.findByName(context, Group.ANONYMOUS); + policy.setGroup(group); + policy.setAction(Constants.READ); + policy.setStartDate(new LocalDate(9999, 12, 31).toDate()); + policies.add(policy); + authorizeService.removeAllPolicies(context, anotherBitstream); + authorizeService.addPolicies(context, policies, anotherBitstream); + context.restoreAuthSystemState(); + String status = helper.getAccessStatusFromItem(context, itemWithoutPrimaryAndMultipleBitstreams, threshold); + assertThat("testWithNoPrimaryAndMultipleBitstreams 0", status, equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SubmissionFormConverter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SubmissionFormConverter.java index 4555d8b00a..4febcd5594 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SubmissionFormConverter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SubmissionFormConverter.java @@ -155,6 +155,7 @@ public class SubmissionFormConverter implements DSpaceConverter languageCodes; + /** + * The list of type bind value + */ + private List typeBind; + /** * Getter for {@link #selectableMetadata} * @@ -266,6 +271,14 @@ public class SubmissionFormFieldRest { } } + public List getTypeBind() { + return typeBind; + } + + public void setTypeBind(List typeBind) { + this.typeBind = typeBind; + } + public SelectableRelationship getSelectableRelationship() { return selectableRelationship; } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/hateoas/AccessStatusResource.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/hateoas/AccessStatusResource.java new file mode 100644 index 0000000000..c5cd2a5aee --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/hateoas/AccessStatusResource.java @@ -0,0 +1,31 @@ +/** + * 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.model.hateoas; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import org.dspace.app.rest.model.AccessStatusRest; +import org.dspace.app.rest.model.hateoas.annotations.RelNameDSpaceResource; + +/** + * Access Status Rest HAL Resource. The HAL Resource wraps the REST Resource + * adding support for the links and embedded resources + */ +@RelNameDSpaceResource(AccessStatusRest.NAME) +public class AccessStatusResource extends HALResource { + + @JsonUnwrapped + private AccessStatusRest data; + + public AccessStatusResource(AccessStatusRest entry) { + super(entry); + } + + public AccessStatusRest getData() { + return data; + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java new file mode 100644 index 0000000000..b2660f51e0 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java @@ -0,0 +1,61 @@ +/** + * 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.repository; + +import java.sql.SQLException; +import java.util.UUID; +import javax.annotation.Nullable; +import javax.servlet.http.HttpServletRequest; + +import org.dspace.access.status.service.AccessStatusService; +import org.dspace.app.rest.model.AccessStatusRest; +import org.dspace.app.rest.model.ItemRest; +import org.dspace.app.rest.projection.Projection; +import org.dspace.content.Item; +import org.dspace.content.service.ItemService; +import org.dspace.core.Context; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; +import org.springframework.data.rest.webmvc.ResourceNotFoundException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Component; + +/** + * Link repository for calculating the access status of an Item + */ +@Component(ItemRest.CATEGORY + "." + ItemRest.NAME + "." + ItemRest.ACCESS_STATUS) +public class ItemAccessStatusLinkRepository extends AbstractDSpaceRestRepository + implements LinkRestRepository { + + @Autowired + ItemService itemService; + + @Autowired + AccessStatusService accessStatusService; + + @PreAuthorize("hasPermission(#itemId, 'ITEM', 'READ')") + public AccessStatusRest getAccessStatus(@Nullable HttpServletRequest request, + UUID itemId, + @Nullable Pageable optionalPageable, + Projection projection) { + try { + Context context = obtainContext(); + Item item = itemService.find(context, itemId); + if (item == null) { + throw new ResourceNotFoundException("No such item: " + itemId); + } + AccessStatusRest accessStatusRest = new AccessStatusRest(); + String accessStatus = accessStatusService.getAccessStatus(context, item); + accessStatusRest.setStatus(accessStatus); + return accessStatusRest; + } catch (SQLException e) { + throw new RuntimeException(e); + } + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/DescribeStep.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/DescribeStep.java index 10a96a557f..814a180284 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/DescribeStep.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/DescribeStep.java @@ -31,6 +31,8 @@ import org.dspace.content.InProgressSubmission; import org.dspace.content.MetadataValue; import org.dspace.core.Context; import org.dspace.core.Utils; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; /** * Describe step for DSpace Spring Rest. Expose and allow patching of the in progress submission metadata. It is @@ -43,7 +45,11 @@ public class DescribeStep extends AbstractProcessingStep { private static final Logger log = org.apache.logging.log4j.LogManager.getLogger(DescribeStep.class); + // Input reader for form configuration private DCInputsReader inputReader; + // Configuration service + private final ConfigurationService configurationService = + DSpaceServicesFactory.getInstance().getConfigurationService(); public DescribeStep() throws DCInputsReaderException { inputReader = new DCInputsReader(); @@ -64,8 +70,17 @@ public class DescribeStep extends AbstractProcessingStep { private void readField(InProgressSubmission obj, SubmissionStepConfig config, DataDescribe data, DCInputSet inputConfig) throws DCInputsReaderException { + String documentTypeValue = ""; + List documentType = itemService.getMetadataByMetadataString(obj.getItem(), + configurationService.getProperty("submit.type-bind.field", "dc.type")); + if (documentType.size() > 0) { + documentTypeValue = documentType.get(0).getValue(); + } for (DCInput[] row : inputConfig.getFields()) { for (DCInput input : row) { + // Is this input allowed for the document type, as per type bind config? If there is no type + // bind set, this is always true + boolean allowed = input.isAllowedFor(documentTypeValue); List fieldsName = new ArrayList(); if (input.isQualdropValue()) { @@ -91,20 +106,30 @@ public class DescribeStep extends AbstractProcessingStep { String[] metadataToCheck = Utils.tokenize(md.getMetadataField().toString()); if (data.getMetadata().containsKey( Utils.standardize(metadataToCheck[0], metadataToCheck[1], metadataToCheck[2], "."))) { - data.getMetadata() - .get(Utils.standardize(md.getMetadataField().getMetadataSchema().getName(), - md.getMetadataField().getElement(), - md.getMetadataField().getQualifier(), - ".")) - .add(dto); + // If field is allowed by type bind, add value to existing field set, otherwise remove + // all values for this field + if (allowed) { + data.getMetadata() + .get(Utils.standardize(md.getMetadataField().getMetadataSchema().getName(), + md.getMetadataField().getElement(), + md.getMetadataField().getQualifier(), + ".")) + .add(dto); + } else { + data.getMetadata().remove(Utils.standardize(metadataToCheck[0], metadataToCheck[1], + metadataToCheck[2], ".")); + } } else { - List listDto = new ArrayList<>(); - listDto.add(dto); - data.getMetadata() - .put(Utils.standardize(md.getMetadataField().getMetadataSchema().getName(), - md.getMetadataField().getElement(), - md.getMetadataField().getQualifier(), - "."), listDto); + // Add values only if allowed by type bind + if (allowed) { + List listDto = new ArrayList<>(); + listDto.add(dto); + data.getMetadata() + .put(Utils.standardize(md.getMetadataField().getMetadataSchema().getName(), + md.getMetadataField().getElement(), + md.getMetadataField().getQualifier(), + "."), listDto); + } } } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/validation/MetadataValidation.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/validation/MetadataValidation.java index e5ba916e0f..813924ffb3 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/validation/MetadataValidation.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/validation/MetadataValidation.java @@ -16,6 +16,7 @@ import org.apache.logging.log4j.Logger; import org.dspace.app.rest.model.ErrorRest; import org.dspace.app.rest.repository.WorkspaceItemRestRepository; import org.dspace.app.rest.submit.SubmissionService; +import org.dspace.app.rest.utils.ContextUtil; import org.dspace.app.util.DCInput; import org.dspace.app.util.DCInputSet; import org.dspace.app.util.DCInputsReader; @@ -25,6 +26,7 @@ import org.dspace.content.InProgressSubmission; import org.dspace.content.MetadataValue; import org.dspace.content.authority.service.MetadataAuthorityService; import org.dspace.content.service.ItemService; +import org.dspace.services.ConfigurationService; /** * Execute three validation check on fields validation: @@ -50,12 +52,20 @@ public class MetadataValidation extends AbstractValidation { private MetadataAuthorityService metadataAuthorityService; + private ConfigurationService configurationService; + @Override public List validate(SubmissionService submissionService, InProgressSubmission obj, SubmissionStepConfig config) throws DCInputsReaderException, SQLException { List errors = new ArrayList<>(); + String documentTypeValue = ""; DCInputSet inputConfig = getInputReader().getInputsByFormName(config.getId()); + List documentType = itemService.getMetadataByMetadataString(obj.getItem(), + configurationService.getProperty("submit.type-bind.field", "dc.type")); + if (documentType.size() > 0) { + documentTypeValue = documentType.get(0).getValue(); + } for (DCInput[] row : inputConfig.getFields()) { for (DCInput input : row) { String fieldKey = @@ -71,12 +81,21 @@ public class MetadataValidation extends AbstractValidation { for (int i = 1; i < inputPairs.size(); i += 2) { String fullFieldname = input.getFieldName() + "." + (String) inputPairs.get(i); List mdv = itemService.getMetadataByMetadataString(obj.getItem(), fullFieldname); - validateMetadataValues(mdv, input, config, isAuthorityControlled, fieldKey, errors); - if (mdv.size() > 0 && input.isVisible(DCInput.SUBMISSION_SCOPE)) { - foundResult = true; + // If the input is not allowed for this type, strip it from item metadata. + if (!input.isAllowedFor(documentTypeValue)) { + itemService.removeMetadataValues(ContextUtil.obtainCurrentRequestContext(), + obj.getItem(), mdv); + } else { + validateMetadataValues(mdv, input, config, isAuthorityControlled, fieldKey, errors); + if (mdv.size() > 0 && input.isVisible(DCInput.SUBMISSION_SCOPE)) { + foundResult = true; + } } } - if (input.isRequired() && ! foundResult) { + // If the input is required but not allowed for this type, and we removed, don't throw + // an error - this way, a field can be required for "Book" to which it is bound, but not + // other types. A user may have switched between types before a final deposit + if (input.isRequired() && !foundResult && input.isAllowedFor(documentTypeValue)) { // for this required qualdrop no value was found, add to the list of error fields addError(errors, ERROR_VALIDATION_REQUIRED, "/" + WorkspaceItemRestRepository.OPERATION_PATH_SECTIONS + "/" + config.getId() + "/" + @@ -89,6 +108,12 @@ public class MetadataValidation extends AbstractValidation { for (String fieldName : fieldsName) { List mdv = itemService.getMetadataByMetadataString(obj.getItem(), fieldName); + if (!input.isAllowedFor(documentTypeValue)) { + itemService.removeMetadataValues(ContextUtil.obtainCurrentRequestContext(), obj.getItem(), mdv); + // Continue here, this skips the required check since we've just removed values that previously + // appeared, and the configuration already indicates this field shouldn't be included + continue; + } validateMetadataValues(mdv, input, config, isAuthorityControlled, fieldKey, errors); if ((input.isRequired() && mdv.size() == 0) && input.isVisible(DCInput.SUBMISSION_SCOPE)) { // since this field is missing add to list of error @@ -124,6 +149,10 @@ public class MetadataValidation extends AbstractValidation { } } + public void setConfigurationService(ConfigurationService configurationService) { + this.configurationService = configurationService; + } + public void setItemService(ItemService itemService) { this.itemService = itemService; } diff --git a/dspace-server-webapp/src/main/resources/spring/spring-dspace-addon-validation-services.xml b/dspace-server-webapp/src/main/resources/spring/spring-dspace-addon-validation-services.xml index 0dc968674a..f39d553c96 100644 --- a/dspace-server-webapp/src/main/resources/spring/spring-dspace-addon-validation-services.xml +++ b/dspace-server-webapp/src/main/resources/spring/spring-dspace-addon-validation-services.xml @@ -16,6 +16,7 @@ + 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 95ec537727..c1327355b9 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 @@ -12,6 +12,7 @@ import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasJsonPath; import static org.dspace.app.rest.matcher.MetadataMatcher.matchMetadata; import static org.dspace.app.rest.matcher.MetadataMatcher.matchMetadataDoesNotExist; import static org.dspace.core.Constants.WRITE; +import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.Matchers.emptyOrNullString; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; @@ -3861,6 +3862,8 @@ public class ItemRestRepositoryIT extends AbstractControllerIntegrationTest { .andExpect(jsonPath("$.inArchive", Matchers.is(false))) .andExpect(jsonPath("$._links.self.href", Matchers.containsString("/api/core/items/" + item.getID().toString()))) + .andExpect(jsonPath("$._links.accessStatus.href", + Matchers.containsString("/api/core/items/" + item.getID().toString() + "/accessStatus"))) .andExpect(jsonPath("$._links.bundles.href", Matchers.containsString("/api/core/items/" + item.getID().toString() + "/bundles"))) .andExpect(jsonPath("$._links.mappedCollections.href", @@ -3893,6 +3896,8 @@ public class ItemRestRepositoryIT extends AbstractControllerIntegrationTest { .andExpect(jsonPath("$.inArchive", Matchers.is(false))) .andExpect(jsonPath("$._links.self.href", Matchers.containsString("/api/core/items/" + item.getID().toString()))) + .andExpect(jsonPath("$._links.accessStatus.href", + Matchers.containsString("/api/core/items/" + item.getID().toString() + "/accessStatus"))) .andExpect(jsonPath("$._links.bundles.href", Matchers.containsString("/api/core/items/" + item.getID().toString() + "/bundles"))) .andExpect(jsonPath("$._links.mappedCollections.href", @@ -3926,6 +3931,8 @@ public class ItemRestRepositoryIT extends AbstractControllerIntegrationTest { Matchers.containsString("/api/core/items/" + item.getID().toString()))) .andExpect(jsonPath("$._links.self.href", Matchers.containsString("/api/core/items/" + item.getID().toString()))) + .andExpect(jsonPath("$._links.accessStatus.href", + Matchers.containsString("/api/core/items/" + item.getID().toString() + "/accessStatus"))) .andExpect(jsonPath("$._links.bundles.href", Matchers.containsString("/api/core/items/" + item.getID().toString() + "/bundles"))) .andExpect(jsonPath("$._links.mappedCollections.href", @@ -4376,4 +4383,35 @@ public class ItemRestRepositoryIT extends AbstractControllerIntegrationTest { .andExpect(status().isUnauthorized()); } + @Test + public void findAccessStatusForItemBadRequestTest() throws Exception { + getClient().perform(get("/api/core/items/{uuid}/accessStatus", "1")) + .andExpect(status().isBadRequest()); + } + + @Test + public void findAccessStatusForItemNotFoundTest() throws Exception { + UUID fakeUUID = UUID.randomUUID(); + getClient().perform(get("/api/core/items/{uuid}/accessStatus", fakeUUID)) + .andExpect(status().isNotFound()); + } + + @Test + public void findAccessStatusForItemTest() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection owningCollection = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Owning Collection") + .build(); + Item item = ItemBuilder.createItem(context, owningCollection) + .withTitle("Test item") + .build(); + context.restoreAuthSystemState(); + getClient().perform(get("/api/core/items/{uuid}/accessStatus", item.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status", notNullValue())); + } + } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/SubmissionFormsControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/SubmissionFormsControllerIT.java index 6a83401f93..bcbf957dc6 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/SubmissionFormsControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/SubmissionFormsControllerIT.java @@ -11,6 +11,7 @@ import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -113,20 +114,20 @@ public class SubmissionFormsControllerIT extends AbstractControllerIntegrationTe .startsWith(REST_SERVER_URL + "config/submissionforms/traditionalpageone"))) // check the first two rows .andExpect(jsonPath("$.rows[0].fields", contains( - SubmissionFormFieldMatcher.matchFormFieldDefinition("name", "Author", + SubmissionFormFieldMatcher.matchFormFieldDefinition("name", "Author", null, null, true,"Add an author", "dc.contributor.author")))) .andExpect(jsonPath("$.rows[1].fields", contains( - SubmissionFormFieldMatcher.matchFormFieldDefinition("onebox", "Title", + SubmissionFormFieldMatcher.matchFormFieldDefinition("onebox", "Title", null, "You must enter a main title for this item.", false, "Enter the main title of the item.", "dc.title")))) // check a row with multiple fields .andExpect(jsonPath("$.rows[3].fields", contains( SubmissionFormFieldMatcher.matchFormFieldDefinition("date", "Date of Issue", - "You must enter at least the year.", false, + null, "You must enter at least the year.", false, "Please give the date", "col-sm-4", "dc.date.issued"), - SubmissionFormFieldMatcher.matchFormFieldDefinition("onebox", "Publisher", + SubmissionFormFieldMatcher.matchFormFieldDefinition("onebox", "Publisher", null, null, false,"Enter the name of", "col-sm-8","dc.publisher")))) ; @@ -144,18 +145,18 @@ public class SubmissionFormsControllerIT extends AbstractControllerIntegrationTe .andExpect(jsonPath("$._links.self.href", Matchers .startsWith(REST_SERVER_URL + "config/submissionforms/traditionalpageone"))) .andExpect(jsonPath("$.rows[0].fields", contains( - SubmissionFormFieldMatcher.matchFormFieldDefinition("name", "Author", + SubmissionFormFieldMatcher.matchFormFieldDefinition("name", "Author", null, null, true,"Add an author", "dc.contributor.author")))) .andExpect(jsonPath("$.rows[1].fields", contains( - SubmissionFormFieldMatcher.matchFormFieldDefinition("onebox", "Title", + SubmissionFormFieldMatcher.matchFormFieldDefinition("onebox", "Title", null, "You must enter a main title for this item.", false, "Enter the main title of the item.", "dc.title")))) .andExpect(jsonPath("$.rows[3].fields",contains( - SubmissionFormFieldMatcher.matchFormFieldDefinition("date", "Date of Issue", + SubmissionFormFieldMatcher.matchFormFieldDefinition("date", "Date of Issue", null, "You must enter at least the year.", false, "Please give the date", "col-sm-4", "dc.date.issued"), - SubmissionFormFieldMatcher.matchFormFieldDefinition("onebox", "Publisher", + SubmissionFormFieldMatcher.matchFormFieldDefinition("onebox", "Publisher", null, null, false,"Enter the name of", "col-sm-8","dc.publisher")))); } @@ -220,20 +221,20 @@ public class SubmissionFormsControllerIT extends AbstractControllerIntegrationTe // dc.subject fields with in separate rows all linked to an authority with different // presentation modes (suggestion, name-lookup, lookup) .andExpect(jsonPath("$.rows[0].fields", contains( - SubmissionFormFieldMatcher.matchFormFieldDefinition("onebox", "Author", + SubmissionFormFieldMatcher.matchFormFieldDefinition("onebox", "Author", null, null, true, "Author field that can be associated with an authority providing suggestion", null, "dc.contributor.author", "SolrAuthorAuthority") ))) .andExpect(jsonPath("$.rows[1].fields", contains( - SubmissionFormFieldMatcher.matchFormFieldDefinition("lookup-name", "Editor", + SubmissionFormFieldMatcher.matchFormFieldDefinition("lookup-name", "Editor", null, null, false, "Editor field that can be associated with an authority " + "providing the special name lookup", null, "dc.contributor.editor", "SolrEditorAuthority") ))) .andExpect(jsonPath("$.rows[2].fields", contains( - SubmissionFormFieldMatcher.matchFormFieldDefinition("lookup", "Subject", + SubmissionFormFieldMatcher.matchFormFieldDefinition("lookup", "Subject", null, null, true, "Subject field that can be associated with an authority providing lookup", null, "dc.subject", "SolrSubjectAuthority") @@ -266,7 +267,7 @@ public class SubmissionFormsControllerIT extends AbstractControllerIntegrationTe .startsWith(REST_SERVER_URL + "config/submissionforms/traditionalpageone"))) // our test configuration include the dc.type field with a value pair in the 8th row .andExpect(jsonPath("$.rows[7].fields", contains( - SubmissionFormFieldMatcher.matchFormFieldDefinition("dropdown", "Type", + SubmissionFormFieldMatcher.matchFormFieldDefinition("dropdown", "Type", null, null, true, "Select the type(s) of content of the item. To select more than one value in the " + "list, you may have to hold down the \"CTRL\" or \"Shift\" key.", @@ -275,6 +276,35 @@ public class SubmissionFormsControllerIT extends AbstractControllerIntegrationTe ; } + @Test + public void findFieldWithTypeBindConfig() throws Exception { + String token = getAuthToken(admin.getEmail(), password); + + getClient(token).perform(get("/api/config/submissionforms/traditionalpageone")) + // The status has to be 200 OK + .andExpect(status().isOk()) + // We expect the content type to be "application/hal+json;charset=UTF-8" + .andExpect(content().contentType(contentType)) + // Check that the JSON root matches the expected "traditionalpageone" input forms + .andExpect(jsonPath("$.id", is("traditionalpageone"))) + .andExpect(jsonPath("$.name", is("traditionalpageone"))) + .andExpect(jsonPath("$.type", is("submissionform"))) + .andExpect(jsonPath("$._links.self.href", Matchers + .startsWith(REST_SERVER_URL + "config/submissionforms/traditionalpageone"))) + // check a row with type-bind 'Technical Report' + .andExpect(jsonPath("$.rows[5].fields", contains( + SubmissionFormFieldMatcher.matchFormFieldDefinition("series", "Series/Report No.", + "Technical Report", null, true, + "Enter the series and number assigned to this item by your community.", + "dc.relation.ispartofseries")))) + // check the same row with a NON-matching type-bind 'Article' (expect false) + .andExpect(((jsonPath("$.rows[5].fields", not(contains( + SubmissionFormFieldMatcher.matchFormFieldDefinition("series", "Series/Report No.", + "Article", null, true, + "Enter the series and number assigned to this item by your community.", + "dc.relation.ispartofseries"))))))); + } + @Test public void findOpenRelationshipConfig() throws Exception { String token = getAuthToken(admin.getEmail(), password); @@ -352,14 +382,15 @@ public class SubmissionFormsControllerIT extends AbstractControllerIntegrationTe .andExpect(jsonPath("$._links.self.href", Matchers .startsWith(REST_SERVER_URL + "config/submissionforms/languagetest"))) .andExpect(jsonPath("$.rows[0].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("name", "Autore", "\u00C8" + " richiesto almeno un autore", true, + .matchFormFieldDefinition("name", "Autore", null, + "\u00C8" + " richiesto almeno un autore", true, "Aggiungi un autore", "dc.contributor.author")))) .andExpect(jsonPath("$.rows[1].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("onebox", "Titolo", + .matchFormFieldDefinition("onebox", "Titolo", null, "\u00C8" + " necessario inserire un titolo principale per questo item", false, "Inserisci titolo principale di questo item", "dc.title")))) .andExpect(jsonPath("$.rows[2].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("dropdown", "Lingua", null, false, + .matchFormFieldDefinition("dropdown", "Lingua", null, null, false, "Selezionare la lingua del contenuto principale dell'item." + " Se la lingua non compare nell'elenco, selezionare (Altro)." + " Se il contenuto non ha davvero una lingua" @@ -376,14 +407,14 @@ public class SubmissionFormsControllerIT extends AbstractControllerIntegrationTe .andExpect(jsonPath("$._links.self.href", Matchers .startsWith(REST_SERVER_URL + "config/submissionforms/languagetest"))) .andExpect(jsonPath("$.rows[0].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("name", "Автор", "Потрібно ввести хочаб одного автора!", + .matchFormFieldDefinition("name", "Автор", null, "Потрібно ввести хочаб одного автора!", true, "Додати автора", "dc.contributor.author")))) .andExpect(jsonPath("$.rows[1].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("onebox", "Заголовок", + .matchFormFieldDefinition("onebox", "Заголовок", null, "Заговолок файла обов'язковий !", false, "Ввести основний заголовок файла", "dc.title")))) .andExpect(jsonPath("$.rows[2].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("dropdown", "Мова", null, false, + .matchFormFieldDefinition("dropdown", "Мова", null, null, false, "Виберiть мову головного змiсту файлу, як що мови немає у списку, вибрати (Iнша)." + " Як що вмiст вайлу не є текстовим, наприклад є фотографiєю, тодi вибрати (N/A)", null, "dc.language.iso", "common_iso_languages")))); @@ -431,14 +462,15 @@ public class SubmissionFormsControllerIT extends AbstractControllerIntegrationTe .andExpect(jsonPath("$._links.self.href", Matchers .startsWith(REST_SERVER_URL + "config/submissionforms/languagetest"))) .andExpect(jsonPath("$.rows[0].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("name", "Autore", "\u00C8" + " richiesto almeno un autore", true, + .matchFormFieldDefinition("name", "Autore", null, + "\u00C8" + " richiesto almeno un autore", true, "Aggiungi un autore", "dc.contributor.author")))) .andExpect(jsonPath("$.rows[1].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("onebox", "Titolo", + .matchFormFieldDefinition("onebox", "Titolo", null, "\u00C8" + " necessario inserire un titolo principale per questo item", false, "Inserisci titolo principale di questo item", "dc.title")))) .andExpect(jsonPath("$.rows[2].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("dropdown", "Lingua", null, false, + .matchFormFieldDefinition("dropdown", "Lingua", null, null, false, "Selezionare la lingua del contenuto principale dell'item." + " Se la lingua non compare nell'elenco, selezionare (Altro)." + " Se il contenuto non ha davvero una lingua" @@ -455,14 +487,14 @@ public class SubmissionFormsControllerIT extends AbstractControllerIntegrationTe .andExpect(jsonPath("$._links.self.href", Matchers .startsWith(REST_SERVER_URL + "config/submissionforms/languagetest"))) .andExpect(jsonPath("$.rows[0].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("name", "Автор", "Потрібно ввести хочаб одного автора!", + .matchFormFieldDefinition("name", "Автор", null, "Потрібно ввести хочаб одного автора!", true, "Додати автора", "dc.contributor.author")))) .andExpect(jsonPath("$.rows[1].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("onebox", "Заголовок", + .matchFormFieldDefinition("onebox", "Заголовок", null, "Заговолок файла обов'язковий !", false, "Ввести основний заголовок файла", "dc.title")))) .andExpect(jsonPath("$.rows[2].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("dropdown", "Мова", null, false, + .matchFormFieldDefinition("dropdown", "Мова", null, null, false, "Виберiть мову головного змiсту файлу, як що мови немає у списку, вибрати (Iнша)." + " Як що вмiст вайлу не є текстовим, наприклад є фотографiєю, тодi вибрати (N/A)", null, "dc.language.iso", "common_iso_languages")))); @@ -505,14 +537,15 @@ public class SubmissionFormsControllerIT extends AbstractControllerIntegrationTe .andExpect(jsonPath("$._links.self.href", Matchers .startsWith(REST_SERVER_URL + "config/submissionforms/languagetest"))) .andExpect(jsonPath("$.rows[0].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("name", "Autore", "\u00C8" + " richiesto almeno un autore", true, + .matchFormFieldDefinition("name", "Autore", null, + "\u00C8" + " richiesto almeno un autore", true, "Aggiungi un autore", "dc.contributor.author")))) .andExpect(jsonPath("$.rows[1].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("onebox", "Titolo", + .matchFormFieldDefinition("onebox", "Titolo", null, "\u00C8" + " necessario inserire un titolo principale per questo item", false, "Inserisci titolo principale di questo item", "dc.title")))) .andExpect(jsonPath("$.rows[2].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("dropdown", "Lingua", null, false, + .matchFormFieldDefinition("dropdown", "Lingua", null, null, false, "Selezionare la lingua del contenuto principale dell'item." + " Se la lingua non compare nell'elenco, selezionare (Altro)." + " Se il contenuto non ha davvero una lingua" @@ -547,10 +580,10 @@ public class SubmissionFormsControllerIT extends AbstractControllerIntegrationTe .andExpect(jsonPath("$._links.self.href", Matchers .startsWith(REST_SERVER_URL + "config/submissionforms/languagetest"))) .andExpect(jsonPath("$.rows[0].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("name", "Autore", "\u00C8 richiesto almeno un autore", true, + .matchFormFieldDefinition("name", "Autore", null, "\u00C8 richiesto almeno un autore", true, "Aggiungi un autore", "dc.contributor.author")))) .andExpect(jsonPath("$.rows[1].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("onebox", "Titolo", + .matchFormFieldDefinition("onebox", "Titolo", null, "\u00C8 necessario inserire un titolo principale per questo item", false, "Inserisci titolo principale di questo item", "dc.title")))); resetLocalesConfiguration(); @@ -582,10 +615,10 @@ public class SubmissionFormsControllerIT extends AbstractControllerIntegrationTe .andExpect(jsonPath("$._links.self.href", Matchers .startsWith(REST_SERVER_URL + "config/submissionforms/languagetest"))) .andExpect(jsonPath("$.rows[0].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("name", "Autore", "\u00C8 richiesto almeno un autore", true, + .matchFormFieldDefinition("name", "Autore", null, "\u00C8 richiesto almeno un autore", true, "Aggiungi un autore", "dc.contributor.author")))) .andExpect(jsonPath("$.rows[1].fields", contains(SubmissionFormFieldMatcher - .matchFormFieldDefinition("onebox", "Titolo", + .matchFormFieldDefinition("onebox", "Titolo", null, "\u00C8 necessario inserire un titolo principale per questo item", false, "Inserisci titolo principale di questo item", "dc.title")))); diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/WorkspaceItemRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/WorkspaceItemRestRepositoryIT.java index 2988d3c350..22c2c4a0ee 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/WorkspaceItemRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/WorkspaceItemRestRepositoryIT.java @@ -1935,6 +1935,141 @@ public class WorkspaceItemRestRepositoryIT extends AbstractControllerIntegration ; } + @Test + /** + * Test the update of metadata for fields configured with type-bind + * + * @throws Exception + */ + public void patchUpdateMetadataWithBindTest() throws Exception { + context.turnOffAuthorisationSystem(); + + //** GIVEN ** + //1. A community-collection structure with one parent community with sub-community and two collections. + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Community child1 = CommunityBuilder.createSubCommunity(context, parentCommunity) + .withName("Sub Community") + .build(); + Collection col1 = CollectionBuilder.createCollection(context, child1).withName("Collection 1").build(); + String authToken = getAuthToken(eperson.getEmail(), password); + + WorkspaceItem witem = WorkspaceItemBuilder.createWorkspaceItem(context, col1) + .withTitle("Workspace Item 1") + .withIssueDate("2017-10-17") + .withSubject("ExtraEntry") + .grantLicense() + .build(); + + //disable file upload mandatory + configurationService.setProperty("webui.submit.upload.required", false); + + context.restoreAuthSystemState(); + + // Try to add isPartOfSeries (type bound to technical report) - this should not work and instead we'll get + // no JSON path for that field + List updateSeries = new ArrayList(); + List> seriesValues = new ArrayList<>(); + Map value = new HashMap(); + value.put("value", "New Series"); + seriesValues.add(value); + updateSeries.add(new AddOperation("/sections/traditionalpageone/dc.relation.ispartofseries", seriesValues)); + + String patchBody = getPatchContent(updateSeries); + + getClient(authToken).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(patchBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.errors").doesNotExist()) + .andExpect(jsonPath("$", + // Check this - we should match an item with no series or type + Matchers.is(WorkspaceItemMatcher.matchItemWithTypeAndSeries(witem, null, null)))); + + // Verify that the metadata isn't in the workspace item + getClient(authToken).perform(get("/api/submission/workspaceitems/" + witem.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.errors").doesNotExist()) + .andExpect(jsonPath("$", + // Check this - we should match an item with no series or type + Matchers.is(WorkspaceItemMatcher.matchItemWithTypeAndSeries(witem, null, null)))); + + // Set the type to Technical Report confirm it worked + List updateType = new ArrayList<>(); + List> typeValues = new ArrayList<>(); + value = new HashMap(); + value.put("value", "Technical Report"); + typeValues.add(value); + updateType.add(new AddOperation("/sections/traditionalpageone/dc.type", typeValues)); + patchBody = getPatchContent(updateType); + + getClient(authToken).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(patchBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.errors").doesNotExist()) + .andExpect(jsonPath("$", + // Check this - we should now match an item with the expected type and series + Matchers.is(WorkspaceItemMatcher.matchItemWithTypeAndSeries(witem, "Technical Report", + null)))); + + getClient(authToken).perform(get("/api/submission/workspaceitems/" + witem.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.errors").doesNotExist()) + .andExpect(jsonPath("$", + Matchers.is(WorkspaceItemMatcher.matchItemWithTypeAndSeries(witem, "Technical Report", + null)))); + + // Another test, this time adding the series value should be successful and we'll see the value + patchBody = getPatchContent(updateSeries); + + getClient(authToken).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(patchBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.errors").doesNotExist()) + .andExpect(jsonPath("$", + // Check this - we should match an item with the expected series and type + Matchers.is(WorkspaceItemMatcher.matchItemWithTypeAndSeries(witem, + "Technical Report", "New Series")))); + + // Verify that the metadata isn't in the workspace item + getClient(authToken).perform(get("/api/submission/workspaceitems/" + witem.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.errors").doesNotExist()) + .andExpect(jsonPath("$", + // Check this - we should match an item with the expected series and type + Matchers.is(WorkspaceItemMatcher.matchItemWithTypeAndSeries(witem, + "Technical Report", "New Series")))); + + // One final update, to a different type, this should lose the series as we're back to a non-matching type + updateType = new ArrayList<>(); + typeValues = new ArrayList<>(); + value = new HashMap(); + value.put("value", "Article"); + typeValues.add(value); + updateType.add(new AddOperation("/sections/traditionalpageone/dc.type", typeValues)); + patchBody = getPatchContent(updateType); + + getClient(authToken).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(patchBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.errors").doesNotExist()) + .andExpect(jsonPath("$", + // Check this - we should NOT match an item with the series "New Series" + Matchers.is(WorkspaceItemMatcher.matchItemWithTypeAndSeries(witem, "Article", + null)))); + + getClient(authToken).perform(get("/api/submission/workspaceitems/" + witem.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.errors").doesNotExist()) + .andExpect(jsonPath("$", + Matchers.is(WorkspaceItemMatcher.matchItemWithTypeAndSeries(witem, "Article", + null)))); + } + @Test public void patchUpdateMetadataForbiddenTest() throws Exception { context.turnOffAuthorisationSystem(); diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/ItemMatcher.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/ItemMatcher.java index 389b8bf492..371ad6b4b4 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/ItemMatcher.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/ItemMatcher.java @@ -50,6 +50,7 @@ public class ItemMatcher { */ public static Matcher matchFullEmbeds() { return matchEmbeds( + "accessStatus", "bundles[]", "mappedCollections[]", "owningCollection", @@ -65,6 +66,7 @@ public class ItemMatcher { */ public static Matcher matchLinks(UUID uuid) { return HalMatcher.matchLinks(REST_SERVER_URL + "core/items/" + uuid, + "accessStatus", "bundles", "mappedCollections", "owningCollection", diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/SubmissionFormFieldMatcher.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/SubmissionFormFieldMatcher.java index 773a751b9f..47f96fd136 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/SubmissionFormFieldMatcher.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/SubmissionFormFieldMatcher.java @@ -10,6 +10,7 @@ package org.dspace.app.rest.matcher; import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasJsonPath; import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasNoJsonPath; import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; @@ -28,13 +29,15 @@ public class SubmissionFormFieldMatcher { /** * Shortcut for the - * {@link SubmissionFormFieldMatcher#matchFormFieldDefinition(String, String, String, boolean, String, String, String, String)} + * {@link SubmissionFormFieldMatcher#matchFormFieldDefinition(String, String, String, String, boolean, String, String, String, String)} * with a null style and vocabulary name * * @param type * the expected input type * @param label * the expected label + * @param typeBind + * the expected type-bind field(s) * @param mandatoryMessage * the expected mandatoryMessage, can be null. If not empty the fiedl is expected to be flagged as * mandatory @@ -46,21 +49,23 @@ public class SubmissionFormFieldMatcher { * the expected metadata * @return a Matcher for all the condition above */ - public static Matcher matchFormFieldDefinition(String type, String label, String mandatoryMessage, - boolean repeatable, + public static Matcher matchFormFieldDefinition(String type, String label, String typeBind, + String mandatoryMessage, boolean repeatable, String hints, String metadata) { - return matchFormFieldDefinition(type, label, mandatoryMessage, repeatable, hints, null, metadata); + return matchFormFieldDefinition(type, label, typeBind, mandatoryMessage, repeatable, hints, null, metadata); } /** * Shortcut for the - * {@link SubmissionFormFieldMatcher#matchFormFieldDefinition(String, String, String, boolean, String, String, String, String)} + * {@link SubmissionFormFieldMatcher#matchFormFieldDefinition(String, String, String, String, boolean, String, String, String, String)} * with a null controlled vocabulary * * @param type * the expected input type * @param label * the expected label + * @param typeBind + * the expected type-bind field(s) * @param mandatoryMessage * the expected mandatoryMessage, can be null. If not empty the field is expected to be flagged as * mandatory @@ -75,10 +80,10 @@ public class SubmissionFormFieldMatcher { * the expected metadata * @return a Matcher for all the condition above */ - public static Matcher matchFormFieldDefinition(String type, String label, String mandatoryMessage, - boolean repeatable, - String hints, String style, String metadata) { - return matchFormFieldDefinition(type, label, mandatoryMessage, repeatable, hints, style, metadata, null); + public static Matcher matchFormFieldDefinition(String type, String label, String typeBind, + String mandatoryMessage, boolean repeatable, String hints, String style, String metadata) { + return matchFormFieldDefinition(type, label, typeBind, mandatoryMessage, repeatable, hints, style, metadata, + null); } /** @@ -88,6 +93,8 @@ public class SubmissionFormFieldMatcher { * the expected input type * @param label * the expected label + * @param typeBind + * the expected type-bind field(s) * @param mandatoryMessage * the expected mandatoryMessage, can be null. If not empty the field is expected to be flagged as * mandatory @@ -100,18 +107,20 @@ public class SubmissionFormFieldMatcher { * missing * @param metadata * the expected metadata - * @param controlled vocabulary + * @param controlledVocabulary * the expected controlled vocabulary, can be null. If null the corresponding json path is expected to be * missing * @return a Matcher for all the condition above */ - public static Matcher matchFormFieldDefinition(String type, String label, String mandatoryMessage, - boolean repeatable, String hints, String style, - String metadata, String controlledVocabulary) { + public static Matcher matchFormFieldDefinition(String type, String label, String typeBind, + String mandatoryMessage, boolean repeatable, + String hints, String style, String metadata, + String controlledVocabulary) { return allOf( // check each field definition hasJsonPath("$.input.type", is(type)), hasJsonPath("$.label", containsString(label)), + typeBind != null ? hasJsonPath("$.typeBind", contains(typeBind)) : hasNoJsonPath("$.typeBind[0]"), hasJsonPath("$.selectableMetadata[0].metadata", is(metadata)), controlledVocabulary != null ? hasJsonPath("$.selectableMetadata[0].controlledVocabulary", is(controlledVocabulary)) : hasNoJsonPath("$.selectableMetadata[0].controlledVocabulary"), @@ -166,7 +175,7 @@ public class SubmissionFormFieldMatcher { hasJsonPath("$.selectableRelationship.filter", is(filter)), hasJsonPath("$.selectableRelationship.searchConfiguration", is(searchConfiguration)), hasJsonPath("$.selectableRelationship.nameVariants", is(String.valueOf(nameVariants))), - matchFormFieldDefinition(type, label, mandatoryMessage, repeatable, hints, metadata)); + matchFormFieldDefinition(type, label, null, mandatoryMessage, repeatable, hints, metadata)); } /** diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/WorkspaceItemMatcher.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/WorkspaceItemMatcher.java index d2d2491171..ecd9cef4e8 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/WorkspaceItemMatcher.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/WorkspaceItemMatcher.java @@ -82,6 +82,30 @@ public class WorkspaceItemMatcher { matchLinks(witem)); } + /** + * Check that the workspace item has the expected type and series values + * (used in type bind evaluation) + * @param witem the workspace item + * @param type the dc.type value eg. Technical Report + * @param series the series value eg. 11-23 + * @return Matcher result + */ + public static Matcher matchItemWithTypeAndSeries(WorkspaceItem witem, String type, String series) { + return allOf( + // Check workspaceitem properties + matchProperties(witem), + // Check type appears or is null + type != null ? + hasJsonPath("$.sections.traditionalpageone['dc.type'][0].value", is(type)) : + hasNoJsonPath("$.sections.traditionalpageone['dc.type'][0].value"), + // Check series as it appears (for type bind testing) + series != null ? + hasJsonPath("$.sections.traditionalpageone['dc.relation.ispartofseries'][0].value", is(series)) : + hasNoJsonPath("$.sections.traditionalpageone['dc.relation.ispartofseries'][0].value"), + matchLinks(witem) + ); + } + /** * Check that the id and type are exposed * diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/model/AccessStatusRestTest.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/model/AccessStatusRestTest.java new file mode 100644 index 0000000000..7dfe3e69e0 --- /dev/null +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/model/AccessStatusRestTest.java @@ -0,0 +1,39 @@ +/** + * 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.model; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import org.dspace.access.status.DefaultAccessStatusHelper; +import org.junit.Before; +import org.junit.Test; + +/** + * Test the AccessStatusRestTest class + */ +public class AccessStatusRestTest { + + AccessStatusRest accessStatusRest; + + @Before + public void setUp() throws Exception { + accessStatusRest = new AccessStatusRest(); + } + + @Test + public void testAccessStatusIsNullBeforeStatusSet() throws Exception { + assertNull(accessStatusRest.getStatus()); + } + + @Test + public void testAccessStatusIsNotNullAfterStatusSet() throws Exception { + accessStatusRest.setStatus(DefaultAccessStatusHelper.UNKNOWN); + assertNotNull(accessStatusRest.getStatus()); + } +} diff --git a/dspace/config/dspace.cfg b/dspace/config/dspace.cfg index 5b82edd518..a0a1a61ad6 100644 --- a/dspace/config/dspace.cfg +++ b/dspace/config/dspace.cfg @@ -806,6 +806,22 @@ plugin.single.org.dspace.embargo.EmbargoSetter = org.dspace.embargo.DefaultEmbar # implementation of embargo lifter plugin - - replace with local implementation if applicable plugin.single.org.dspace.embargo.EmbargoLifter = org.dspace.embargo.DefaultEmbargoLifter +# 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 + #### Checksum Checker Settings #### # Default dispatcher in case none specified plugin.single.org.dspace.checker.BitstreamDispatcher=org.dspace.checker.SimpleDispatcher @@ -920,6 +936,11 @@ metadata.hide.dc.description.provenance = true # Defaults to true; If set to 'false', submitter has option to skip upload #webui.submit.upload.required = true +# Which field should be used for type-bind +# Defaults to 'dc.type'; If changing this value, you must also update the related +# dspace-angular environment configuration property submission.typeBind.field +#submit.type-bind.field = dc.type + #### Creative Commons settings ###### # The url to the web service API @@ -1584,6 +1605,12 @@ request.item.type = all # Should all Request Copy emails go to the helpdesk instead of the item submitter? request.item.helpdesk.override = false +#------------------------------------------------------------------# +#------------------SUBMISSION CONFIGURATION------------------------# +#------------------------------------------------------------------# +# Field to use for type binding, default dc.type +submit.type-bind.field = dc.type + #------------------------------------------------------------------# #-------------------MODULE CONFIGURATIONS--------------------------# #------------------------------------------------------------------# diff --git a/dspace/config/modules/rest.cfg b/dspace/config/modules/rest.cfg index 1b0895810b..302cd650a3 100644 --- a/dspace/config/modules/rest.cfg +++ b/dspace/config/modules/rest.cfg @@ -33,6 +33,7 @@ rest.projection.specificLevel.maxEmbed = 5 rest.properties.exposed = plugin.named.org.dspace.curate.CurationTask rest.properties.exposed = google.analytics.key rest.properties.exposed = versioning.item.history.include.submitter +rest.properties.exposed = submit.type-bind.field #---------------------------------------------------------------# # These configs are used by the deprecated REST (v4-6) module # diff --git a/dspace/config/spring/api/core-factory-services.xml b/dspace/config/spring/api/core-factory-services.xml index 2712ad21d0..6aadb591f2 100644 --- a/dspace/config/spring/api/core-factory-services.xml +++ b/dspace/config/spring/api/core-factory-services.xml @@ -27,6 +27,7 @@ + diff --git a/dspace/config/spring/api/core-services.xml b/dspace/config/spring/api/core-services.xml index 591a4ef3f4..16c0af78a7 100644 --- a/dspace/config/spring/api/core-services.xml +++ b/dspace/config/spring/api/core-services.xml @@ -86,8 +86,9 @@ - + +