View Javadoc
1   /**
2    * Copyright 2005-2016 The Kuali Foundation
3    *
4    * Licensed under the Educational Community License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    * http://www.opensource.org/licenses/ecl2.php
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  package org.kuali.rice.kew.actions;
17  
18  import org.apache.commons.lang.ArrayUtils;
19  import org.apache.commons.lang.BooleanUtils;
20  import org.apache.commons.lang.StringUtils;
21  import org.apache.log4j.Logger;
22  import org.jdom.input.DOMBuilder;
23  import org.kuali.rice.core.api.CoreApiServiceLocator;
24  import org.kuali.rice.core.api.exception.RiceRuntimeException;
25  import org.kuali.rice.core.api.util.xml.XmlHelper;
26  import org.kuali.rice.coreservice.framework.CoreFrameworkServiceLocator;
27  import org.kuali.rice.coreservice.impl.CoreServiceImplServiceLocator;
28  import org.kuali.rice.kew.actionrequest.ActionRequestFactory;
29  import org.kuali.rice.kew.actionrequest.ActionRequestValue;
30  import org.kuali.rice.kew.actionrequest.KimGroupRecipient;
31  import org.kuali.rice.kew.actionrequest.KimPrincipalRecipient;
32  import org.kuali.rice.kew.actionrequest.Recipient;
33  import org.kuali.rice.kew.actiontaken.ActionTakenValue;
34  import org.kuali.rice.kew.api.KewApiConstants;
35  import org.kuali.rice.kew.api.KewApiServiceLocator;
36  import org.kuali.rice.kew.api.WorkflowRuntimeException;
37  import org.kuali.rice.kew.api.action.ActionRequestPolicy;
38  import org.kuali.rice.kew.api.action.ActionRequestType;
39  import org.kuali.rice.kew.api.action.ActionTaken;
40  import org.kuali.rice.kew.api.action.ActionType;
41  import org.kuali.rice.kew.api.doctype.DocumentTypePolicy;
42  import org.kuali.rice.kew.api.doctype.ProcessDefinition;
43  import org.kuali.rice.kew.api.exception.InvalidActionTakenException;
44  import org.kuali.rice.kew.doctype.bo.DocumentType;
45  import org.kuali.rice.kew.engine.node.ProcessDefinitionBo;
46  import org.kuali.rice.kew.engine.node.RouteNode;
47  import org.kuali.rice.kew.role.KimRoleRecipient;
48  import org.kuali.rice.kew.routeheader.DocumentRouteHeaderValue;
49  import org.kuali.rice.kew.rule.RuleResponsibilityBo;
50  import org.kuali.rice.kew.service.KEWServiceLocator;
51  import org.kuali.rice.kew.xml.CommonXmlParser;
52  import org.kuali.rice.kim.api.identity.principal.PrincipalContract;
53  import org.kuali.rice.kim.api.role.Role;
54  import org.kuali.rice.kim.api.services.KimApiServiceLocator;
55  import org.kuali.rice.krad.service.KRADServiceLocator;
56  import org.w3c.dom.Document;
57  import org.w3c.dom.Element;
58  import org.w3c.dom.Node;
59  import org.w3c.dom.NodeList;
60  import org.xml.sax.InputSource;
61  import org.xml.sax.SAXException;
62  
63  import javax.xml.parsers.DocumentBuilderFactory;
64  import javax.xml.parsers.ParserConfigurationException;
65  import java.io.IOException;
66  import java.io.StringReader;
67  import java.util.ArrayList;
68  import java.util.Arrays;
69  import java.util.Collection;
70  import java.util.Collections;
71  import java.util.Comparator;
72  import java.util.HashMap;
73  import java.util.HashSet;
74  import java.util.List;
75  import java.util.Map;
76  import java.util.Set;
77  
78  /**
79   * @since 2.1
80   */
81  public class RecallAction extends ReturnToPreviousNodeAction {
82      private static final Logger LOG = Logger.getLogger(RecallAction.class);
83  
84      protected final boolean cancel;
85      protected final Collection<Recipient> notificationRecipients;
86  
87      /**
88       * Constructor required for ActionRegistry validation
89       */
90      public RecallAction(DocumentRouteHeaderValue routeHeader, PrincipalContract principal) {
91          this(routeHeader, principal, null, true, true, true);
92      }
93  
94      public RecallAction(DocumentRouteHeaderValue routeHeader, PrincipalContract principal, String annotation, boolean cancel) {
95          this(routeHeader, principal, annotation, cancel, true, true);
96      }
97  
98      public RecallAction(DocumentRouteHeaderValue routeHeader, PrincipalContract principal, String annotation, boolean cancel, boolean sendNotifications) {
99          this(routeHeader, principal, annotation, cancel, sendNotifications, true);
100     }
101 
102     public RecallAction(DocumentRouteHeaderValue routeHeader, PrincipalContract principal, String annotation, boolean cancel, boolean sendNotifications, boolean runPostProcessorLogic) {
103         super(ActionType.RECALL.getCode(), routeHeader, principal,
104               principal.getPrincipalName() + " recalled document" + (StringUtils.isBlank(annotation) ? "" : ": " + annotation),
105               INITIAL_NODE_NAME,
106               sendNotifications, runPostProcessorLogic);
107         this.cancel = cancel;
108         this.notificationRecipients = Collections.unmodifiableCollection(parseNotificationRecipients(routeHeader));
109     }
110 
111     /**
112      * Parses notification recipients from the RECALL_NOTIFICATION document type policy, if present
113      * @param routeHeader this document
114      * @return notification recipient RuleResponsibilityBos
115      */
116     protected static Collection<Recipient> parseNotificationRecipients(DocumentRouteHeaderValue routeHeader) {
117         Collection<Recipient> toNotify = new ArrayList<Recipient>();
118         org.kuali.rice.kew.doctype.DocumentTypePolicy recallNotification = routeHeader.getDocumentType().getRecallNotification();
119         if (recallNotification != null) {
120             String config = recallNotification.getPolicyStringValue();
121             if (!StringUtils.isBlank(config)) {
122                 Document notificationConfig;
123                 try {
124                     notificationConfig = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(new StringReader(config)));
125                 } catch (Exception e) {
126                     throw new RuntimeException(e);
127                 }
128                 DOMBuilder jdom = new DOMBuilder();
129                 // ok, so using getElementsByTagName is less strict than it could be because it inspects all descendants
130                 // not just immediate children.  but the w3c dom API stinks and this is less painful than manually searching immediate children
131                 NodeList recipients = notificationConfig.getDocumentElement().getElementsByTagName("recipients");
132                 for (int i = 0; i < recipients.getLength(); i++) {
133                     NodeList children = recipients.item(i).getChildNodes();
134                     for (int j = 0; j < children.getLength(); j++) {
135                         Node n = children.item(j);
136                         if (n instanceof Element) {
137                             Element e = (Element) n;
138                             if ("role".equals(e.getNodeName())) {
139                                 String ns = e.getAttribute("namespace");
140                                 String name = e.getAttribute("name");
141                                 Role role = KimApiServiceLocator.getRoleService().getRoleByNamespaceCodeAndName(ns, name);
142                                 if (role == null) {
143                                     LOG.error("No role round: " + ns + ":" + name);
144                                 } else {
145                                     toNotify.add(new KimRoleRecipient(role));
146                                 }
147                             } else {
148                                 // parseResponsibilityNameAndType parses a single responsibility choice from a parent element
149                                 // wrap the element with a parent so we can use this method
150                                 org.jdom.Element parent = new org.jdom.Element("parent");
151                                 parent.addContent(jdom.build((Element) e.cloneNode(true)).detach());
152                                 toNotify.add(CommonXmlParser.parseResponsibilityNameAndType(parent).getRecipient());
153                             }
154                         }
155                     }
156                 }
157             }
158         }
159         return toNotify;
160     }
161 
162     @Override
163     public String validateActionRules(List<ActionRequestValue> actionRequests) {
164         if (!getRouteHeader().isValidActionToTake(getActionPerformedCode())) {
165             return "Document of status '" + getRouteHeader().getDocRouteStatus() + "' cannot taken action '" + ActionType.fromCode(this.getActionTakenCode()).getLabel() + "' to node name " + this.nodeName;
166         }
167 
168         // validate that recall action can be taken given prior actions taken
169         String errMsg = validateActionsTaken(getRouteHeader());
170         if (errMsg != null) {
171             return errMsg;
172         }
173 
174         // validate that the doc will actually route to anybody
175         errMsg = validateRouting(getRouteHeader());
176         if (errMsg != null) {
177             return errMsg;
178         }
179 
180 
181         if (!KEWServiceLocator.getDocumentTypePermissionService().canRecall(getPrincipal().getPrincipalId(), getRouteHeader())) {
182             return "User is not authorized to Recall document";
183         }
184         return "";
185     }
186 
187     /**
188      * Determines whether prior actions taken are compatible with recall action by checking the RECALL_VALID_ACTIONSTAKEN
189      * document type policy.
190      * @param rh the DocumentRouteHeaderValue
191      * @return null if valid (policy not specified, no actions taken, or all actions taken are in valid actions taken list), or error message if invalid
192      */
193     protected String validateActionsTaken(DocumentRouteHeaderValue rh) {
194         String validActionsTaken = rh.getDocumentType().getPolicyByName(DocumentTypePolicy.RECALL_VALID_ACTIONSTAKEN.getCode(), (String) null).getPolicyStringValue();
195         if (StringUtils.isNotBlank(validActionsTaken)) {
196             // interpret as comma-separated list of codes OR labels
197             String[] validActionsTakenStrings = validActionsTaken.split("\\s*,\\s*");
198             Set<ActionType> validActionTypes = new HashSet<ActionType>(validActionsTakenStrings.length);
199             for (String s: validActionsTakenStrings) {
200                 ActionType at = ActionType.fromCodeOrLabel(s);
201                 if (at == null) {
202                     throw new IllegalArgumentException("Failed to locate the ActionType with the given code or label: " + s);
203                 }
204                 validActionTypes.add(at);
205             }
206 
207             Collection<ActionTakenValue> actionsTaken = KEWServiceLocator.getActionTakenService().getActionsTaken(getRouteHeader().getDocumentId());
208 
209             for (ActionTakenValue actionTaken: actionsTaken) {
210                 ActionType at = ActionType.fromCode(actionTaken.getActionTaken());
211                 if (!validActionTypes.contains(at)) {
212                     return "Invalid prior action taken: '" + at.getLabel() + "'. Cannot Recall.";
213                 }
214             }
215         }
216         return null;
217     }
218 
219     /**
220      * Determines whether the doc's type appears to statically define any routing.  If not, then Recall action
221      * doesn't make much sense, and should not be available.  Checks whether any document type processes are defined,
222      * and if so, whether are are any non-"adhoc" nodes (based on literal node name check).
223      * @param rh the DocumentRouteHeaderValue
224      * @return error message if it looks like it's this doc will not route to a person based on static route definition, null (valid) otherwise
225      */
226     protected String validateRouting(DocumentRouteHeaderValue rh) {
227         List<ProcessDefinitionBo> processes = rh.getDocumentType().getProcesses();
228 
229         String errMsg = null;
230         if (processes.size() == 0) {
231             // if no processes are present then this doc isn't going to route to anyone
232             errMsg = "No processes are defined for document type. Recall is not applicable.";
233         } else {
234             // if there is not at least one route node not named "ADHOC", then assume this doc will not route to anybody
235             RouteNode initialRouteNode = rh.getDocumentType().getPrimaryProcess().getInitialRouteNode();
236             if (initialRouteNode.getName().equalsIgnoreCase("ADHOC") && initialRouteNode.getNextNodeIds().isEmpty()) {
237                 errMsg = "No non-adhoc route nodes defined for document type. Recall is not applicable";
238             }
239         }
240         return errMsg;
241     }
242 
243     @Override // overridden to simply pass through all actionrequests
244     protected List<ActionRequestValue> findApplicableActionRequests(List<ActionRequestValue> actionRequests) {
245         return actionRequests;
246     }
247 
248     @Override // overridden to implement Recall action validation
249     public boolean isActionCompatibleRequest(List<ActionRequestValue> requests) {
250         return true;
251     }
252 
253     @Override // When invoked, RECALL TO ACTION LIST action will return the document back to their action list with the route status of SAVED and requested action of COMPLETE.
254     protected ActionRequestType getReturnToInitiatorActionRequestType() {
255         return ActionRequestType.COMPLETE;
256     }
257 
258     /**
259      * Override the default return to previous behavior so that the document is returned to the recaller, not initiator
260      */
261     @Override
262     protected PrincipalContract determineInitialNodePrincipal(DocumentRouteHeaderValue routeHeader) {
263         return getPrincipal();
264     }
265 
266     @Override
267     protected void sendAdditionalNotifications() {
268         super.sendAdditionalNotifications();
269         // NOTE: do we need construct w/ nodeInstance here?
270         ActionRequestFactory arFactory = new ActionRequestFactory(routeHeader);
271         for (Recipient recipient: this.notificationRecipients) {
272             if (!(recipient instanceof KimRoleRecipient)) {
273                 arFactory.addRootActionRequest(ActionRequestType.FYI.getCode(), 0, recipient, "Document was recalled", KewApiConstants.MACHINE_GENERATED_RESPONSIBILITY_ID, null, null, null);
274             } else {
275                 KimRoleRecipient kimRoleRecipient = (KimRoleRecipient) recipient;
276                 // no qualifications
277                 Map<String, String> qualifications = Collections.emptyMap();
278                 LOG.info("Adding KIM Role Request for " + kimRoleRecipient.getRole());
279                 arFactory.addKimRoleRequest(ActionRequestType.FYI.getCode(), 0, kimRoleRecipient.getRole(),
280                                             KimApiServiceLocator.getRoleService().getRoleMembers(Collections.singletonList(kimRoleRecipient.getRole().getId()), qualifications),
281                                             "Document was recalled", KewApiConstants.MACHINE_GENERATED_RESPONSIBILITY_ID,
282                                             // force action, so the member is notified even if they have already participated in the workflow
283                                             // since this is a notification about an exceptional case they should receive
284                                             true, ActionRequestPolicy.FIRST.getCode(), "Document was recalled");
285             }
286         }
287         getActionRequestService().activateRequests(arFactory.getRequestGraphs());
288     }
289 
290     @Override
291     public void recordAction() throws InvalidActionTakenException {
292         if (this.cancel) {
293             // perform action validity check here as well as WorkflowDocumentServiceImpl calls recordAction immediate after action construction
294             String errorMessage = validateActionRules(getActionRequestService().findAllPendingRequests(getDocumentId()));
295             if (!org.apache.commons.lang.StringUtils.isEmpty(errorMessage)) {
296                 throw new InvalidActionTakenException(errorMessage);
297             }
298             // we are in cancel mode, execute a cancel action with RECALL code
299             // NOTE: this performs CancelAction validation, including getDocumentTypePermissionService().canCancel
300             // stub out isActionCompatibleRequest since we are programmatically forcing this action
301             new CancelAction(ActionType.RECALL, this.routeHeader, this.getPrincipal(), this.annotation) {
302                 @Override
303                 public boolean isActionCompatibleRequest(List<ActionRequestValue> requests) {
304                     return true;
305                 }
306                 @Override
307                 protected void markDocumentStatus() throws InvalidActionTakenException {
308                     getRouteHeader().markDocumentRecalled();
309                 }
310             }.recordAction();
311         } else {
312             super.recordAction();
313             // When invoked, RECALL TO ACTION LIST action will return the document back to their action list with the route status of SAVED and requested action of COMPLETE.
314             String oldStatus = getRouteHeader().getDocRouteStatus();
315             getRouteHeader().markDocumentSaved();
316             String newStatus = getRouteHeader().getDocRouteStatus();
317             notifyStatusChange(newStatus, oldStatus);
318             KEWServiceLocator.getRouteHeaderService().saveRouteHeader(routeHeader);
319         }
320         // we don't have an established mechanism for exposing the action taken that is saved as the result of a (nested) action
321         // so use the last action take saved
322         ActionTakenValue last = getLastActionTaken(getDocumentId());
323         if (last != null) {
324             notifyAfterActionTaken(last);
325         }
326     }
327 
328     /**
329      * Returns the last action taken on a document
330      * @param docId the doc id
331      * @return last action taken on a document
332      */
333     protected static ActionTakenValue getLastActionTaken(String docId) {
334         ActionTakenValue last = null;
335         Collection<ActionTakenValue> actionsTaken = (Collection<ActionTakenValue>) KEWServiceLocator.getActionTakenService().getActionsTaken(docId);
336         for (ActionTakenValue at: actionsTaken) {
337             if (last == null || at.getActionDate().after(last.getActionDate())) {
338                 last = at;
339             }
340         }
341         return last;
342     }
343 }