diff --git a/modules/data-entry/src/main/features/feature.json b/modules/data-entry/src/main/features/feature.json index 08c2a31e71..1aa83d23fc 100644 --- a/modules/data-entry/src/main/features/feature.json +++ b/modules/data-entry/src/main/features/feature.json @@ -71,7 +71,8 @@ "service.ranking:Integer":150, "scripts":[ "create path (cards:dataQuery) /query \n\n # Allow all users to query; the actual results will obey their access rights \n set ACL for everyone \n allow jcr:read on /query \n end \n\n create path (cards:QuestionnairesHomepage) /Questionnaires \n create path (cards:FormsHomepage) /Forms \n create path (cards:QueryCacheHomepage) /QueryCache \n create path (cards:SubjectsHomepage) /Subjects \n create path (cards:SubjectTypesHomepage) /SubjectTypes ", - "create service user cards-answer-editor \n set ACL on /Questionnaires \n allow jcr:read for cards-answer-editor \n end" + "create service user cards-answer-editor \n set ACL on /Questionnaires \n allow jcr:read for cards-answer-editor \n end", + "create service user cards-reference-answer-editor \n set ACL for cards-reference-answer-editor \n allow jcr:read,rep:write,jcr:versionManagement on /Questionnaires,/Forms \n end" ] }, "org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended~cards-data-entry":{ @@ -80,7 +81,8 @@ "io.uhndata.cards.data-model-forms-impl:computedAnswers=[cards-answer-editor]", "io.uhndata.cards.data-model-forms-impl:referenceAnswers=[cards-answer-editor]", "io.uhndata.cards.data-model-forms-impl:maxFormsOfTypePerSubjectValidator=[sling-readall]", - "io.uhndata.cards.data-model-forms-impl:requiredSubjectTypesValidator=[sling-readall]" + "io.uhndata.cards.data-model-forms-impl:requiredSubjectTypesValidator=[sling-readall]", + "io.uhndata.cards.data-model-forms-impl:referenceAnswersChangedListener=[cards-reference-answer-editor]" ] } } diff --git a/modules/data-model/forms/api/src/main/java/io/uhndata/cards/forms/api/ExpressionUtils.java b/modules/data-model/forms/api/src/main/java/io/uhndata/cards/forms/api/ExpressionUtils.java index 507e56e586..2c49305cd3 100644 --- a/modules/data-model/forms/api/src/main/java/io/uhndata/cards/forms/api/ExpressionUtils.java +++ b/modules/data-model/forms/api/src/main/java/io/uhndata/cards/forms/api/ExpressionUtils.java @@ -78,4 +78,11 @@ default String evaluate(Node question, Map values) * expression has unmet dependencies or the actual evaluation result cannot be converted to the desired type */ Object evaluate(Node question, Map values, Type type); + + /** + * + * @param question the question node + * @return list of all the questions names that is used to compute an answer + */ + Set getQuestionsNames(Node question); } diff --git a/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/AnswersEditor.java b/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/AnswersEditor.java index 659bca60f9..14af272718 100644 --- a/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/AnswersEditor.java +++ b/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/AnswersEditor.java @@ -39,6 +39,7 @@ import org.apache.sling.api.resource.ResourceResolver; import org.apache.sling.api.resource.ResourceResolverFactory; import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import io.uhndata.cards.forms.api.FormUtils; import io.uhndata.cards.forms.api.QuestionnaireUtils; @@ -48,6 +49,7 @@ */ public abstract class AnswersEditor extends DefaultEditor { + private static final Logger LOGGER = LoggerFactory.getLogger(AnswersEditor.class); // This holds the builder for the current node. The methods called for editing specific properties don't receive the // actual parent node of those properties, so we must manually keep track of the current node. protected final NodeBuilder currentNodeBuilder; @@ -154,6 +156,16 @@ protected Node getQuestionnaire() } } + protected Node getForm() + { + final String formId = this.currentNodeBuilder.getProperty("jcr:uuid").getValue(Type.STRING); + try { + return this.serviceSession.getNodeByIdentifier(formId); + } catch (RepositoryException e) { + return null; + } + } + // Returns a QuestionTree if any children of this node contains an unanswered matching question, else null protected QuestionTree getUnansweredMatchingQuestions(final Node currentNode) { diff --git a/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ComputedAnswersEditor.java b/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ComputedAnswersEditor.java index 945c4bc9f4..d51485e52f 100644 --- a/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ComputedAnswersEditor.java +++ b/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ComputedAnswersEditor.java @@ -175,6 +175,9 @@ private void computeAnswer(final Map.Entry entry, @SuppressWarnings("unchecked") Type untypedResultType = (Type) resultType; answer.setProperty(FormUtils.VALUE_PROPERTY, result, untypedResultType); + Set computedFromQuestionPaths = + getQuestionPathsFromNames(this.expressionUtils.getQuestionsNames(question)); + answer.setProperty("computedFrom", computedFromQuestionPaths, Type.STRINGS); } // Update the computed value in the map of existing answers String questionName = this.questionnaireUtils.getQuestionName(question); @@ -186,6 +189,23 @@ private void computeAnswer(final Map.Entry entry, } } + private Set getQuestionPathsFromNames(final Set names) + { + Set paths = new HashSet<>(); + Node formNode = getForm(); + Node questionnaire = getQuestionnaire(); + try { + for (String computedFromQuestionName : names) { + Node questionNode = this.questionnaireUtils.getQuestion(questionnaire, computedFromQuestionName); + Node changingAnswer = this.formUtils.getAnswer(formNode, questionNode); + paths.add(changingAnswer.getPath()); + } + } catch (RepositoryException e) { + LOGGER.error("Error getting path of question. " + e.getMessage()); + } + return paths; + } + private List sortDependencies(final Map> computedAnswerDependencies) { final List result = new ArrayList<>(); diff --git a/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ExpressionUtilsImpl.java b/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ExpressionUtilsImpl.java index 67868e756d..75758dfeff 100644 --- a/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ExpressionUtilsImpl.java +++ b/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ExpressionUtilsImpl.java @@ -23,6 +23,7 @@ import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -90,6 +91,43 @@ public Object evaluate(final Node question, final Map values, fi return null; } + @Override + public Set getQuestionsNames(Node question) + { + return expressionInputs(getExpressionFromQuestion(question)); + } + + private Set expressionInputs(final String expression) + { + String expr = expression; + + Set questionNames = new HashSet<>(); + + int start = expr.indexOf(START_MARKER); + int end = expr.indexOf(END_MARKER, start); + + while (start > -1 && end > -1) { + int defaultStart = expr.indexOf(DEFAULT_MARKER, start); + boolean hasDefault = defaultStart > -1 && defaultStart < end; + + String questionName; + if (hasDefault) { + questionName = expr.substring(start + START_MARKER.length(), defaultStart); + } else { + questionName = expr.substring(start + START_MARKER.length(), end); + } + + questionNames.add(questionName); + + // Remove the start and end tags + expr = expr.substring(0, start) + questionName + expr.substring(end + END_MARKER.length()); + + start = expr.indexOf(START_MARKER); + end = expr.indexOf(END_MARKER, start); + } + return questionNames; + } + private ExpressionUtilsImpl.ParsedExpression parseExpressionInputs(final String expression, final Map values) { diff --git a/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ReferenceAnswersChangedListener.java b/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ReferenceAnswersChangedListener.java new file mode 100644 index 0000000000..455af18944 --- /dev/null +++ b/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ReferenceAnswersChangedListener.java @@ -0,0 +1,299 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.uhndata.cards.forms.internal; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.jcr.Node; +import javax.jcr.NodeIterator; +import javax.jcr.Property; +import javax.jcr.RepositoryException; +import javax.jcr.Session; +import javax.jcr.Value; +import javax.jcr.version.VersionManager; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.api.resource.observation.ResourceChange; +import org.apache.sling.api.resource.observation.ResourceChangeListener; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.uhndata.cards.forms.api.FormUtils; +import io.uhndata.cards.forms.api.QuestionnaireUtils; +import io.uhndata.cards.resolverProvider.ThreadResourceResolverProvider; + +/** + * Change listener looking for modified Forms whose Answers are referenced in other Forms. Initially, when the Form is + * changed, this handler goes through all the Answers which belong to the Form and checks whether a given Answer is + * referenced elsewhere. If so, the source and referenced Answer values are compared and if they do not match the + * referenced value is updated to match the source value. + * + * @version $Id$ + */ +@Component(immediate = true, property = { + ResourceChangeListener.PATHS + "=/Forms", + ResourceChangeListener.CHANGES + "=CHANGED" +}) +public class ReferenceAnswersChangedListener implements ResourceChangeListener +{ + /** Answer's property name. **/ + public static final String VALUE = "value"; + private static final Logger LOGGER = LoggerFactory.getLogger(ReferenceAnswersChangedListener.class); + + /** Provides access to resources. */ + @Reference + private volatile ResourceResolverFactory resolverFactory; + + @Reference + private ThreadResourceResolverProvider rrp; + + @Reference + private FormUtils formUtils; + + @Reference + private QuestionnaireUtils questionnaireUtils; + + @Override + public void onChange(List changes) + { + changes.forEach(this::handleEvent); + } + + /** + * For every Form change detected by the listener, this handler goes through all Answers composing the changed + * Form and updates the values of all the referenced Answers according to changes in the source Answers. + * + * @param event a change that happened in the repository + */ + private void handleEvent(final ResourceChange event) + { + final Map parameters = + Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "referenceAnswersChangedListener"); + + try (ResourceResolver localResolver = this.resolverFactory.getServiceResourceResolver(parameters)) { + // Get the information needed from the triggering form + final Session session = localResolver.adaptTo(Session.class); + if (!session.nodeExists(event.getPath())) { + return; + } + final String path = event.getPath(); + final Node form = session.getNode(path); + if (!this.formUtils.isForm(form)) { + return; + } + try { + this.rrp.push(localResolver); + NodeIterator children = form.getNodes(); + final VersionManager versionManager = session.getWorkspace().getVersionManager(); + Set nodesToBeDeleted = new HashSet<>(); + Set updatedReferenceAnswersPaths = checkAndUpdateAnswersValues(children, localResolver, session, + versionManager); + for (String answerPath : updatedReferenceAnswersPaths) { + nodesToBeDeleted.addAll(searchComputedReferenceAnswers(answerPath, session)); + } + deleteComputedReferenceAnswersValues(nodesToBeDeleted, session, versionManager); + } catch (RepositoryException e) { + LOGGER.error(e.getMessage(), e); + } finally { + this.rrp.pop(); + } + + } catch (final LoginException e) { + LOGGER.warn("Failed to get service session: {}", e.getMessage(), e); + } catch (final RepositoryException e) { + LOGGER.error(e.getMessage(), e); + } + } + + /** + * This method reads through a NodeIterator of changed Nodes. If a given changed Node is a cards/Answer node + * all other cards/Answer nodes that make reference to it are updated so that the value property of the + * referenced Node matches the value property of the changed node. + * + * @param nodeIterator an iterator of nodes of which have changed due to an update made to a Form + * @param serviceResolver a ResourceResolver that can be used for querying the JCR + * @param session a service session providing access to the repository + */ + private Set checkAndUpdateAnswersValues(final NodeIterator nodeIterator, + final ResourceResolver serviceResolver, final Session session, + final VersionManager versionManager) throws RepositoryException + { + Set changedReferenceAnswersPaths = new HashSet<>(); + Set checkoutPaths = new HashSet<>(); + while (nodeIterator.hasNext()) { + final Node node = nodeIterator.nextNode(); + if (node.isNodeType("cards:AnswerSection")) { + checkAndUpdateAnswersValues(node.getNodes(), serviceResolver, session, versionManager); + } else if (node.hasProperty("sling:resourceSuperType") + && "cards/Answer".equals(node.getProperty("sling:resourceSuperType").getString())) { + final Iterator resourceIteratorReferencingAnswers = serviceResolver.findResources( + "SELECT a.* FROM [cards:Answer] AS a WHERE a.copiedFrom = '" + node.getPath() + "'", + "JCR-SQL2"); + while (resourceIteratorReferencingAnswers.hasNext()) { + final Resource referenceAnswer = resourceIteratorReferencingAnswers.next(); + + if (!referenceAnswer.getValueMap().containsKey(VALUE)) { + continue; + } + if (!node.hasProperty(VALUE)) { + continue; + } + final Property sourceAnswerValue = node.getProperty(VALUE); + final Object referenceAnswerValue = referenceAnswer.getValueMap().get(VALUE); + if (!referenceAnswerValue.toString().equals(sourceAnswerValue.getString())) { + final String referenceFormPath = getParentFormPath(referenceAnswer); + versionManager.checkout(referenceFormPath); + checkoutPaths.add(referenceFormPath); + final Node formNode = session.getNode(referenceFormPath); + Node changingAnswer = this.formUtils.getAnswer(formNode, + getQuestionNode(referenceAnswer, session, serviceResolver)); + changingAnswer.setProperty(VALUE, sourceAnswerValue.getValue()); + changedReferenceAnswersPaths.add(referenceAnswer.getPath()); + } + } + } + } + session.save(); + for (String path : checkoutPaths) { + versionManager.checkin(path); + } + return changedReferenceAnswersPaths; + } + + private Set searchComputedReferenceAnswers(String computedFromAnswerPath, Session session) + throws RepositoryException + { + final Node formNode = getParentFormNode(session.getNode(computedFromAnswerPath)); + final NodeIterator children = formNode.getNodes(); + return searchForComputedReferencesAnswers(children, computedFromAnswerPath); + } + + private Set searchForComputedReferencesAnswers(final NodeIterator nodes, + final String changedReferenceAnswerPath) + throws RepositoryException + { + Set nodesToBeDeleted = new HashSet<>(); + while (nodes.hasNext()) { + final Node node = nodes.nextNode(); + if (node.isNodeType("cards:AnswerSection")) { + nodesToBeDeleted.addAll( + searchForComputedReferencesAnswers(node.getNodes(), changedReferenceAnswerPath)); + continue; + } + + if (!node.hasProperty("computedFrom")) { + continue; + } + Value[] computedFromPropertyValues = node.getProperty("computedFrom").getValues(); + + for (Value computedFromPropertyValue : computedFromPropertyValues) { + if (!changedReferenceAnswerPath.equals(computedFromPropertyValue.getString())) { + continue; + } + nodesToBeDeleted.add(node); + break; + } + } + return nodesToBeDeleted; + } + + private void deleteComputedReferenceAnswersValues(final Set nodesToBeDeleted, final Session session, + final VersionManager versionManager) throws RepositoryException + { + Set checkoutPaths = new HashSet<>(); + for (Node node : nodesToBeDeleted) { + final String formPath = getParentFormNode(node).getPath(); + versionManager.checkout(formPath); + checkoutPaths.add(formPath); + node.remove(); + } + session.save(); + for (String path : checkoutPaths) { + versionManager.checkin(path); + } + } + + /** + * Gets the Question node for given Answer node. + * + * @param answer answer for which the question is sought + * @param session a service session providing access to the repository + * @param serviceResolver a ResourceResolver that can be used for querying the JCR + * @return node of question to reference answer + * @throws RepositoryException if the form data could not be accessed + */ + private Node getQuestionNode(final Resource answer, final Session session, final ResourceResolver serviceResolver) + throws RepositoryException + { + String questionUUID = answer.getValueMap().get("question").toString(); + final Iterator resourceIteratorQuestion = serviceResolver.findResources( + "SELECT q.* FROM [cards:Question] AS q WHERE q.'jcr:uuid' = '" + questionUUID + "'", + "JCR-SQL2"); + if (!resourceIteratorQuestion.hasNext()) { + return null; + } + return session.getNode(resourceIteratorQuestion.next().getPath()); + } + + /** + * Gets the path of the parent Form for a given descendant node. + * + * @param child node for which the parent form is sought + * @return string of path the parent form + */ + private String getParentFormPath(Resource child) + { + Resource parent = child.getParent(); + + if (parent == null) { + return null; + } + if (!parent.isResourceType("cards/Form")) { + return getParentFormPath(parent); + } + return parent.getPath(); + } + + /** + * Gets the node of the parent Form for a given descendant node. + * + * @param child node for which the parent form is sought + * @return node of the parent form + */ + private Node getParentFormNode(Node child) throws RepositoryException + { + Node parent = child.getParent(); + + if (parent == null) { + return null; + } + if (!parent.isNodeType("cards:Form")) { + return getParentFormNode(parent); + } + return parent; + } +} diff --git a/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ReferenceAnswersEditor.java b/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ReferenceAnswersEditor.java index 0fc98581b7..7e6396328c 100644 --- a/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ReferenceAnswersEditor.java +++ b/modules/data-model/forms/impl/src/main/java/io/uhndata/cards/forms/internal/ReferenceAnswersEditor.java @@ -136,7 +136,7 @@ protected void handleLeave(final NodeState form) NodeBuilder answer = entry.getValue(); Type resultType = getAnswerType(question); - Object result = getAnswer(form, referencedQuestion); + ReferenceAnswer result = getAnswer(form, referencedQuestion); if (result == null) { answer.removeProperty(FormUtils.VALUE_PROPERTY); @@ -145,14 +145,16 @@ protected void handleLeave(final NodeState form) // The implementation can extract the right type from the type object @SuppressWarnings("unchecked") Type untypedResultType = - (Type) (result instanceof List ? resultType.getArrayType() : resultType); - answer.setProperty(FormUtils.VALUE_PROPERTY, result, untypedResultType); + (Type) (result.getValue() instanceof List ? resultType.getArrayType() : resultType); + answer.setProperty(FormUtils.VALUE_PROPERTY, result.getValue(), untypedResultType); + answer.setProperty("copiedFrom", result.getPath()); } + }); } } - private Object getAnswer(NodeState form, String questionPath) + private ReferenceAnswer getAnswer(NodeState form, String questionPath) { Node subject = this.formUtils.getSubject(form); try { @@ -160,9 +162,10 @@ private Object getAnswer(NodeState form, String questionPath) this.formUtils.findAllSubjectRelatedAnswers(subject, this.serviceSession.getNode(questionPath), EnumSet.allOf(FormUtils.SearchType.class)); if (!answers.isEmpty()) { - Object value = this.formUtils.getValue(answers.iterator().next()); + Node answer = answers.iterator().next(); + Object value = this.formUtils.getValue(answer); if (value != null) { - return serializeValue(value); + return new ReferenceAnswer(serializeValue(value), answer.getPath()); } } } catch (RepositoryException e) { @@ -213,4 +216,27 @@ public boolean isMatchedAnswerNode(NodeState after, String questionId) return false; } } + + + private static final class ReferenceAnswer + { + private final Object value; + private final String path; + + ReferenceAnswer(Object value, String path) + { + this.value = value; + this.path = path; + } + + public Object getValue() + { + return this.value; + } + + public String getPath() + { + return this.path; + } + } }