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 }