diff --git a/dspace-api/src/main/java/org/dspace/app/ldn/action/LDNCorrectionAction.java b/dspace-api/src/main/java/org/dspace/app/ldn/action/LDNCorrectionAction.java index e136ff1098..a302c1478f 100644 --- a/dspace-api/src/main/java/org/dspace/app/ldn/action/LDNCorrectionAction.java +++ b/dspace-api/src/main/java/org/dspace/app/ldn/action/LDNCorrectionAction.java @@ -7,11 +7,15 @@ */ package org.dspace.app.ldn.action; +import java.math.BigDecimal; +import java.sql.SQLException; import java.util.Date; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.dspace.app.ldn.NotifyServiceEntity; import org.dspace.app.ldn.model.Notification; +import org.dspace.app.ldn.service.LDNMessageService; import org.dspace.content.Item; import org.dspace.content.QAEvent; import org.dspace.content.service.ItemService; @@ -33,14 +37,16 @@ public class LDNCorrectionAction implements LDNAction { protected ItemService itemService; @Autowired private QAEventService qaEventService; + @Autowired + private LDNMessageService ldnMessageService; @Override public ActionStatus execute(Notification notification, Item item) throws Exception { - ActionStatus result = ActionStatus.ABORT; + ActionStatus result; Context context = ContextUtil.obtainCurrentRequestContext(); QAEvent qaEvent = new QAEvent(QAEvent.COAR_NOTIFY, notification.getObject().getId(), item.getID().toString(), item.getName(), - this.getQaEventTopic(), 0d, + this.getQaEventTopic(), getScore(context, notification).doubleValue(), "{\"abstracts[0]\": \"" + notification.getObject().getIetfCiteAs() + "\"}" , new Date()); qaEventService.store(context, qaEvent); @@ -49,6 +55,21 @@ public class LDNCorrectionAction implements LDNAction { return result; } + private BigDecimal getScore(Context context, Notification notification) throws SQLException { + + if (notification.getOrigin() == null) { + return BigDecimal.ZERO; + } + + NotifyServiceEntity service = ldnMessageService.findNotifyService(context, notification.getOrigin()); + + if (service == null) { + return BigDecimal.ZERO; + } + + return service.getScore(); + } + public String getQaEventTopic() { return qaEventTopic; } diff --git a/dspace-api/src/main/java/org/dspace/app/ldn/service/LDNMessageService.java b/dspace-api/src/main/java/org/dspace/app/ldn/service/LDNMessageService.java index bbf2396c3d..b99c998c11 100644 --- a/dspace-api/src/main/java/org/dspace/app/ldn/service/LDNMessageService.java +++ b/dspace-api/src/main/java/org/dspace/app/ldn/service/LDNMessageService.java @@ -11,7 +11,9 @@ import java.sql.SQLException; import java.util.List; import org.dspace.app.ldn.LDNMessageEntity; +import org.dspace.app.ldn.NotifyServiceEntity; import org.dspace.app.ldn.model.Notification; +import org.dspace.app.ldn.model.Service; import org.dspace.core.Context; /** @@ -94,4 +96,14 @@ public interface LDNMessageService { * @param context The DSpace context */ public int extractAndProcessMessageFromQueue(Context context) throws SQLException; + + /** + * find the related notify service entity + * + * @param context the context + * @param service the service + * @return the NotifyServiceEntity + * @throws SQLException if something goes wrong + */ + public NotifyServiceEntity findNotifyService(Context context, Service service) throws SQLException; } diff --git a/dspace-api/src/main/java/org/dspace/app/ldn/service/impl/LDNMessageServiceImpl.java b/dspace-api/src/main/java/org/dspace/app/ldn/service/impl/LDNMessageServiceImpl.java index 4bb6cd92f5..3b720dab0b 100644 --- a/dspace-api/src/main/java/org/dspace/app/ldn/service/impl/LDNMessageServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/app/ldn/service/impl/LDNMessageServiceImpl.java @@ -148,7 +148,7 @@ public class LDNMessageServiceImpl implements LDNMessageService { return null; } - private NotifyServiceEntity findNotifyService(Context context, Service service) throws SQLException { + public NotifyServiceEntity findNotifyService(Context context, Service service) throws SQLException { return notifyServiceDao.findByLdnUrl(context, service.getInbox()); } diff --git a/dspace-api/src/main/java/org/dspace/content/QAEvent.java b/dspace-api/src/main/java/org/dspace/content/QAEvent.java index dd1070589a..df1b53982e 100644 --- a/dspace-api/src/main/java/org/dspace/content/QAEvent.java +++ b/dspace-api/src/main/java/org/dspace/content/QAEvent.java @@ -197,6 +197,7 @@ public class QAEvent { public Class getMessageDtoClass() { switch (getSource()) { case OPENAIRE_SOURCE: + case COAR_NOTIFY: return OpenaireMessageDTO.class; default: throw new IllegalArgumentException("Unknown event's source: " + getSource()); diff --git a/dspace-api/src/main/java/org/dspace/qaevent/AutomaticProcessingAction.java b/dspace-api/src/main/java/org/dspace/qaevent/AutomaticProcessingAction.java new file mode 100644 index 0000000000..771650746d --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/qaevent/AutomaticProcessingAction.java @@ -0,0 +1,17 @@ +/** + * 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.qaevent; + +/** + * Enumeration of possible actions to perform over a {@link org.dspace.content.QAEvent} + * + * @author Mohamed Eskander (mohamed.eskander at 4science.com) + */ +public enum AutomaticProcessingAction { + REJECT, ACCEPT, IGNORE +} diff --git a/dspace-api/src/main/java/org/dspace/qaevent/QAEventAutomaticProcessingEvaluation.java b/dspace-api/src/main/java/org/dspace/qaevent/QAEventAutomaticProcessingEvaluation.java new file mode 100644 index 0000000000..d7c8f3681e --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/qaevent/QAEventAutomaticProcessingEvaluation.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.qaevent; + +import org.dspace.content.QAEvent; +import org.dspace.core.Context; + +/** + * This interface allows the implemnetation of Automation Processing rules + * defining which {@link AutomaticProcessingAction} should be eventually + * performed on a specific {@link QAEvent} + * + * @author Mohamed Eskander (mohamed.eskander at 4science.com) + */ +public interface QAEventAutomaticProcessingEvaluation { + + /** + * Evaluate a {@link QAEvent} to decide which, if any, {@link AutomaticProcessingAction} should be performed + * + * @param context the DSpace context + * @param qaEvent the quality assurance event + * @return an action of {@link AutomaticProcessingAction} or null if no automatic action should be performed + */ + AutomaticProcessingAction evaluateAutomaticProcessing(Context context, QAEvent qaEvent); + +} diff --git a/dspace-api/src/main/java/org/dspace/qaevent/QAScoreAutomaticProcessingEvaluation.java b/dspace-api/src/main/java/org/dspace/qaevent/QAScoreAutomaticProcessingEvaluation.java new file mode 100644 index 0000000000..f685222d3d --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/qaevent/QAScoreAutomaticProcessingEvaluation.java @@ -0,0 +1,151 @@ +/** + * 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.qaevent; + +import java.sql.SQLException; +import java.util.UUID; + +import org.dspace.content.Item; +import org.dspace.content.QAEvent; +import org.dspace.content.logic.LogicalStatement; +import org.dspace.content.service.ItemService; +import org.dspace.core.Context; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * A configurable implementation of {@link QAEventAutomaticProcessingEvaluation} allowing to define thresholds for + * automatic acceptance, rejection or ignore of {@link QAEvent} matching a specific, optional, item filter + * {@link LogicalStatement}. If the item filter is not defined only the score threshold will be used. + * + * @author Mohamed Eskander (mohamed.eskander at 4science.com) + */ +public class QAScoreAutomaticProcessingEvaluation implements QAEventAutomaticProcessingEvaluation { + /** + * The minimum score of QAEvent to be considered for automatic approval (trust must be greater or equals to that) + */ + private double scoreToApprove; + + /** + * The threshold under which QAEvent are considered for automatic ignore (trust must be less or equals to that) + */ + private double scoreToIgnore; + + /** + * The threshold under which QAEvent are considered for automatic rejection (trust must be less or equals to that) + */ + private double scoreToReject; + + /** + * The optional logical statement that must pass for item target of a QAEvent to be considered for automatic + * approval + */ + private LogicalStatement itemFilterToApprove; + + /** + * The optional logical statement that must pass for item target of a QAEvent to be considered for automatic + * ignore + */ + private LogicalStatement itemFilterToIgnore; + + /** + * The optional logical statement that must pass for item target of a QAEvent to be considered for automatic + * rejection + */ + private LogicalStatement itemFilterToReject; + + @Autowired + private ItemService itemService; + + @Override + public AutomaticProcessingAction evaluateAutomaticProcessing(Context context, QAEvent qaEvent) { + Item item = findItem(context, qaEvent.getTarget()); + + if (shouldReject(context, qaEvent.getTrust(), item)) { + return AutomaticProcessingAction.REJECT; + } else if (shouldIgnore(context, qaEvent.getTrust(), item)) { + return AutomaticProcessingAction.IGNORE; + } else if (shouldApprove(context, qaEvent.getTrust(), item)) { + return AutomaticProcessingAction.ACCEPT; + } else { + return null; + } + + } + + private Item findItem(Context context, String uuid) { + try { + return itemService.find(context, UUID.fromString(uuid)); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + private boolean shouldReject(Context context, double trust, Item item) { + return trust <= scoreToReject && + (itemFilterToReject == null || itemFilterToReject.getResult(context, item)); + } + + private boolean shouldIgnore(Context context, double trust, Item item) { + return trust <= scoreToIgnore && + (itemFilterToIgnore == null || itemFilterToIgnore.getResult(context, item)); + } + + private boolean shouldApprove(Context context, double trust, Item item) { + return trust >= scoreToApprove && + (itemFilterToApprove == null || itemFilterToApprove.getResult(context, item)); + } + + public double getScoreToApprove() { + return scoreToApprove; + } + + public void setScoreToApprove(double scoreToApprove) { + this.scoreToApprove = scoreToApprove; + } + + public double getScoreToIgnore() { + return scoreToIgnore; + } + + public void setScoreToIgnore(double scoreToIgnore) { + this.scoreToIgnore = scoreToIgnore; + } + + public double getScoreToReject() { + return scoreToReject; + } + + public void setScoreToReject(double scoreToReject) { + this.scoreToReject = scoreToReject; + } + + public LogicalStatement getItemFilterToApprove() { + return itemFilterToApprove; + } + + public void setItemFilterToApprove(LogicalStatement itemFilterToApprove) { + this.itemFilterToApprove = itemFilterToApprove; + } + + public LogicalStatement getItemFilterToIgnore() { + return itemFilterToIgnore; + } + + public void setItemFilterToIgnore(LogicalStatement itemFilterToIgnore) { + this.itemFilterToIgnore = itemFilterToIgnore; + } + + public LogicalStatement getItemFilterToReject() { + return itemFilterToReject; + } + + public void setItemFilterToReject(LogicalStatement itemFilterToReject) { + this.itemFilterToReject = itemFilterToReject; + } +} + diff --git a/dspace-api/src/main/java/org/dspace/qaevent/service/impl/QAEventServiceImpl.java b/dspace-api/src/main/java/org/dspace/qaevent/service/impl/QAEventServiceImpl.java index f8a01d84cf..d03b12c2c0 100644 --- a/dspace-api/src/main/java/org/dspace/qaevent/service/impl/QAEventServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/qaevent/service/impl/QAEventServiceImpl.java @@ -16,6 +16,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; @@ -41,14 +42,18 @@ import org.dspace.content.QAEvent; import org.dspace.content.service.ItemService; import org.dspace.core.Context; import org.dspace.handle.service.HandleService; +import org.dspace.qaevent.AutomaticProcessingAction; +import org.dspace.qaevent.QAEventAutomaticProcessingEvaluation; import org.dspace.qaevent.QASource; import org.dspace.qaevent.QATopic; import org.dspace.qaevent.dao.QAEventsDao; import org.dspace.qaevent.dao.impl.QAEventsDaoImpl; +import org.dspace.qaevent.service.QAEventActionService; import org.dspace.qaevent.service.QAEventService; import org.dspace.services.ConfigurationService; import org.dspace.services.factory.DSpaceServicesFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; /** * Implementation of {@link QAEventService} that use Solr to store events. When @@ -73,6 +78,13 @@ public class QAEventServiceImpl implements QAEventService { @Autowired private QAEventsDaoImpl qaEventsDao; + @Autowired + @Qualifier("qaAutomaticProcessingMap") + private Map qaAutomaticProcessingMap; + + @Autowired + private QAEventActionService qaEventActionService; + private ObjectMapper jsonMapper; public QAEventServiceImpl() { @@ -321,12 +333,43 @@ public class QAEventServiceImpl implements QAEventService { updateRequest.process(getSolr()); getSolr().commit(); + + performAutomaticProcessingIfNeeded(context, dto); } } catch (Exception e) { throw new RuntimeException(e); } } + private void performAutomaticProcessingIfNeeded(Context context, QAEvent qaEvent) { + QAEventAutomaticProcessingEvaluation evaluation = qaAutomaticProcessingMap.get(qaEvent.getSource()); + + if (evaluation == null) { + return; + } + + AutomaticProcessingAction action = evaluation.evaluateAutomaticProcessing(context, qaEvent); + + if (action == null) { + return; + } + + switch (action) { + case REJECT: + qaEventActionService.reject(context, qaEvent); + break; + case IGNORE: + qaEventActionService.discard(context, qaEvent); + break; + case ACCEPT: + qaEventActionService.accept(context, qaEvent); + break; + default: + throw new IllegalStateException("Unknown automatic action requested " + action); + } + + } + @Override public QAEvent findEventByEventId(String eventId) { SolrQuery param = new SolrQuery(EVENT_ID + ":" + eventId); diff --git a/dspace-api/src/test/data/dspaceFolder/config/spring/api/qaevents-test.xml b/dspace-api/src/test/data/dspaceFolder/config/spring/api/qaevents-test.xml new file mode 100644 index 0000000000..8738d6cbef --- /dev/null +++ b/dspace-api/src/test/data/dspaceFolder/config/spring/api/qaevents-test.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/QAEventRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/QAEventRestRepositoryIT.java index 699a522f5e..a4a3a14a5a 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/QAEventRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/QAEventRestRepositoryIT.java @@ -10,6 +10,7 @@ package org.dspace.app.rest; import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasJsonPath; import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasNoJsonPath; import static org.dspace.app.rest.matcher.QAEventMatcher.matchQAEventEntry; +import static org.dspace.content.QAEvent.COAR_NOTIFY; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.hasSize; @@ -831,4 +832,140 @@ public class QAEventRestRepositoryIT extends AbstractControllerIntegrationTest { assertThat(processedEvent.getEperson().getID(), is(admin.getID())); } + + @Test + public void createQAEventsAndAcceptAutomaticallyByScoreAndFilterTest() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context).withName("Parent Community").build(); + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build(); + Item item = ItemBuilder.createItem(context, col1).withTitle("demo").build(); + + QAEvent event = + QAEventBuilder.createTarget(context, item) + .withSource(COAR_NOTIFY) + .withTrust(0.8) + .withTopic("ENRICH/MORE/REVIEW") + .withMessage("{\"abstracts[0]\": \"https://doi.org/10.3214/987654\"}") + .build(); + + context.restoreAuthSystemState(); + String authToken = getAuthToken(admin.getEmail(), password); + + getClient(authToken).perform(get("/api/core/items/" + item.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.metadata['datacite.relation.isReviewedBy'][0].value", + is("https://doi.org/10.3214/987654"))); + + getClient(authToken).perform(get("/api/integration/qualityassuranceevents/" + event.getEventId())) + .andExpect(status().isNotFound()); + } + + @Test + public void createQAEventsAndIgnoreAutomaticallyByScoreAndFilterTest() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context).withName("Parent Community").build(); + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build(); + Item item = ItemBuilder.createItem(context, col1).withTitle("demo").build(); + + QAEvent event = + QAEventBuilder.createTarget(context, item) + .withSource(COAR_NOTIFY) + .withTrust(0.4) + .withTopic("ENRICH/MORE/REVIEW") + .withMessage("{\"abstracts[0]\": \"https://doi.org/10.3214/987654\"}") + .build(); + + context.restoreAuthSystemState(); + String authToken = getAuthToken(admin.getEmail(), password); + + getClient(authToken).perform(get("/api/core/items/" + item.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.metadata['datacite.relation.isReviewedBy']").doesNotExist()); + + getClient(authToken).perform(get("/api/integration/qualityassuranceevents/" + event.getEventId())) + .andExpect(status().isNotFound()); + } + + @Test + public void createQAEventsAndRejectAutomaticallyByScoreAndFilterTest() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context).withName("Parent Community").build(); + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build(); + Item item = ItemBuilder.createItem(context, col1).withTitle("demo").build(); + + QAEvent event = + QAEventBuilder.createTarget(context, item) + .withSource(COAR_NOTIFY) + .withTrust(0.3) + .withTopic("ENRICH/MORE/REVIEW") + .withMessage("{\"abstracts[0]\": \"https://doi.org/10.3214/987654\"}") + .build(); + + context.restoreAuthSystemState(); + String authToken = getAuthToken(admin.getEmail(), password); + + getClient(authToken).perform(get("/api/core/items/" + item.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.metadata['datacite.relation.isReviewedBy']").doesNotExist()); + + getClient(authToken).perform(get("/api/integration/qualityassuranceevents/" + event.getEventId())) + .andExpect(status().isNotFound()); + } + + @Test + public void createQAEventsAndDoNothingScoreNotInRangTest() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context).withName("Parent Community").build(); + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build(); + Item item = ItemBuilder.createItem(context, col1).withTitle("demo").build(); + + QAEvent event = + QAEventBuilder.createTarget(context, item) + .withSource(COAR_NOTIFY) + .withTrust(0.7) + .withTopic("ENRICH/MORE/REVIEW") + .withMessage("{\"abstracts[0]\": \"https://doi.org/10.3214/987654\"}") + .build(); + + context.restoreAuthSystemState(); + String authToken = getAuthToken(admin.getEmail(), password); + + getClient(authToken).perform(get("/api/core/items/" + item.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.metadata['datacite.relation.isReviewedBy']").doesNotExist()); + + getClient(authToken).perform(get("/api/integration/qualityassuranceevents/" + event.getEventId()) + .param("projection", "full")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", QAEventMatcher.matchQAEventFullEntry(event))); + } + + @Test + public void createQAEventsAndDoNothingFilterNotCompatibleWithItemTest() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context).withName("Parent Community").build(); + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection 1").build(); + Item item = ItemBuilder.createItem(context, col1).withTitle("item title").build(); + + QAEvent event = + QAEventBuilder.createTarget(context, item) + .withSource(COAR_NOTIFY) + .withTrust(0.8) + .withTopic("ENRICH/MORE/REVIEW") + .withMessage("{\"abstracts[0]\": \"https://doi.org/10.3214/987654\"}") + .build(); + + context.restoreAuthSystemState(); + String authToken = getAuthToken(admin.getEmail(), password); + + getClient(authToken).perform(get("/api/core/items/" + item.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.metadata['datacite.relation.isReviewedBy']").doesNotExist()); + + getClient(authToken).perform(get("/api/integration/qualityassuranceevents/" + event.getEventId()) + .param("projection", "full")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", QAEventMatcher.matchQAEventFullEntry(event))); + } + } diff --git a/dspace/config/dspace.cfg b/dspace/config/dspace.cfg index 5b6635383e..33bc582443 100644 --- a/dspace/config/dspace.cfg +++ b/dspace/config/dspace.cfg @@ -928,6 +928,7 @@ registry.metadata.load = schema-publicationVolume-types.xml registry.metadata.load = openaire4-types.xml registry.metadata.load = dspace-types.xml registry.metadata.load = iiif-types.xml +registry.metadata.load = datacite-types.xml #---------------------------------------------------------------# diff --git a/dspace/config/spring/api/qaevents.xml b/dspace/config/spring/api/qaevents.xml index 46a793e566..80349d68e1 100644 --- a/dspace/config/spring/api/qaevents.xml +++ b/dspace/config/spring/api/qaevents.xml @@ -2,10 +2,13 @@ + http://www.springframework.org/schema/context/spring-context-2.5.xsd + http://www.springframework.org/schema/util + http://www.springframework.org/schema/util/spring-util.xsd"> @@ -72,5 +75,25 @@ - + + +