001 /**
002 * Copyright 2005-2013 The Kuali Foundation
003 *
004 * Licensed under the Educational Community License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.opensource.org/licenses/ecl2.php
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016 package org.kuali.rice.kew.actions;
017
018 import org.apache.commons.lang.ArrayUtils;
019 import org.apache.commons.lang.BooleanUtils;
020 import org.apache.commons.lang.StringUtils;
021 import org.apache.log4j.Logger;
022 import org.jdom.input.DOMBuilder;
023 import org.kuali.rice.core.api.CoreApiServiceLocator;
024 import org.kuali.rice.core.api.exception.RiceRuntimeException;
025 import org.kuali.rice.core.api.util.xml.XmlHelper;
026 import org.kuali.rice.coreservice.framework.CoreFrameworkServiceLocator;
027 import org.kuali.rice.coreservice.impl.CoreServiceImplServiceLocator;
028 import org.kuali.rice.kew.actionrequest.ActionRequestFactory;
029 import org.kuali.rice.kew.actionrequest.ActionRequestValue;
030 import org.kuali.rice.kew.actionrequest.KimGroupRecipient;
031 import org.kuali.rice.kew.actionrequest.KimPrincipalRecipient;
032 import org.kuali.rice.kew.actionrequest.Recipient;
033 import org.kuali.rice.kew.actiontaken.ActionTakenValue;
034 import org.kuali.rice.kew.api.KewApiConstants;
035 import org.kuali.rice.kew.api.KewApiServiceLocator;
036 import org.kuali.rice.kew.api.WorkflowRuntimeException;
037 import org.kuali.rice.kew.api.action.ActionRequestPolicy;
038 import org.kuali.rice.kew.api.action.ActionRequestType;
039 import org.kuali.rice.kew.api.action.ActionTaken;
040 import org.kuali.rice.kew.api.action.ActionType;
041 import org.kuali.rice.kew.api.doctype.DocumentTypePolicy;
042 import org.kuali.rice.kew.api.doctype.ProcessDefinition;
043 import org.kuali.rice.kew.api.exception.InvalidActionTakenException;
044 import org.kuali.rice.kew.doctype.bo.DocumentType;
045 import org.kuali.rice.kew.engine.node.ProcessDefinitionBo;
046 import org.kuali.rice.kew.engine.node.RouteNode;
047 import org.kuali.rice.kew.role.KimRoleRecipient;
048 import org.kuali.rice.kew.routeheader.DocumentRouteHeaderValue;
049 import org.kuali.rice.kew.rule.RuleResponsibilityBo;
050 import org.kuali.rice.kew.service.KEWServiceLocator;
051 import org.kuali.rice.kew.xml.CommonXmlParser;
052 import org.kuali.rice.kim.api.identity.principal.PrincipalContract;
053 import org.kuali.rice.kim.api.role.Role;
054 import org.kuali.rice.kim.api.services.KimApiServiceLocator;
055 import org.kuali.rice.krad.service.KRADServiceLocator;
056 import org.w3c.dom.Document;
057 import org.w3c.dom.Element;
058 import org.w3c.dom.Node;
059 import org.w3c.dom.NodeList;
060 import org.xml.sax.InputSource;
061 import org.xml.sax.SAXException;
062
063 import javax.xml.parsers.DocumentBuilderFactory;
064 import javax.xml.parsers.ParserConfigurationException;
065 import java.io.IOException;
066 import java.io.StringReader;
067 import java.util.ArrayList;
068 import java.util.Arrays;
069 import java.util.Collection;
070 import java.util.Collections;
071 import java.util.Comparator;
072 import java.util.HashMap;
073 import java.util.HashSet;
074 import java.util.List;
075 import java.util.Map;
076 import java.util.Set;
077
078 /**
079 * @since 2.1
080 */
081 public class RecallAction extends ReturnToPreviousNodeAction {
082 private static final Logger LOG = Logger.getLogger(RecallAction.class);
083
084 protected final boolean cancel;
085 protected final Collection<Recipient> notificationRecipients;
086
087 /**
088 * Constructor required for ActionRegistry validation
089 */
090 public RecallAction(DocumentRouteHeaderValue routeHeader, PrincipalContract principal) {
091 this(routeHeader, principal, null, true, true, true);
092 }
093
094 public RecallAction(DocumentRouteHeaderValue routeHeader, PrincipalContract principal, String annotation, boolean cancel) {
095 this(routeHeader, principal, annotation, cancel, true, true);
096 }
097
098 public RecallAction(DocumentRouteHeaderValue routeHeader, PrincipalContract principal, String annotation, boolean cancel, boolean sendNotifications) {
099 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 }