001/** 002 * Copyright 2005-2015 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 */ 016package org.kuali.rice.kew.actions; 017 018import org.apache.commons.lang.ArrayUtils; 019import org.apache.commons.lang.BooleanUtils; 020import org.apache.commons.lang.StringUtils; 021import org.apache.log4j.Logger; 022import org.jdom.input.DOMBuilder; 023import org.kuali.rice.core.api.CoreApiServiceLocator; 024import org.kuali.rice.core.api.exception.RiceRuntimeException; 025import org.kuali.rice.core.api.util.xml.XmlHelper; 026import org.kuali.rice.coreservice.framework.CoreFrameworkServiceLocator; 027import org.kuali.rice.coreservice.impl.CoreServiceImplServiceLocator; 028import org.kuali.rice.kew.actionrequest.ActionRequestFactory; 029import org.kuali.rice.kew.actionrequest.ActionRequestValue; 030import org.kuali.rice.kew.actionrequest.KimGroupRecipient; 031import org.kuali.rice.kew.actionrequest.KimPrincipalRecipient; 032import org.kuali.rice.kew.actionrequest.Recipient; 033import org.kuali.rice.kew.actiontaken.ActionTakenValue; 034import org.kuali.rice.kew.api.KewApiConstants; 035import org.kuali.rice.kew.api.KewApiServiceLocator; 036import org.kuali.rice.kew.api.WorkflowRuntimeException; 037import org.kuali.rice.kew.api.action.ActionRequestPolicy; 038import org.kuali.rice.kew.api.action.ActionRequestType; 039import org.kuali.rice.kew.api.action.ActionTaken; 040import org.kuali.rice.kew.api.action.ActionType; 041import org.kuali.rice.kew.api.doctype.DocumentTypePolicy; 042import org.kuali.rice.kew.api.doctype.ProcessDefinition; 043import org.kuali.rice.kew.api.exception.InvalidActionTakenException; 044import org.kuali.rice.kew.doctype.bo.DocumentType; 045import org.kuali.rice.kew.engine.node.ProcessDefinitionBo; 046import org.kuali.rice.kew.engine.node.RouteNode; 047import org.kuali.rice.kew.role.KimRoleRecipient; 048import org.kuali.rice.kew.routeheader.DocumentRouteHeaderValue; 049import org.kuali.rice.kew.rule.RuleResponsibilityBo; 050import org.kuali.rice.kew.service.KEWServiceLocator; 051import org.kuali.rice.kew.xml.CommonXmlParser; 052import org.kuali.rice.kim.api.identity.principal.PrincipalContract; 053import org.kuali.rice.kim.api.role.Role; 054import org.kuali.rice.kim.api.services.KimApiServiceLocator; 055import org.kuali.rice.krad.service.KRADServiceLocator; 056import org.w3c.dom.Document; 057import org.w3c.dom.Element; 058import org.w3c.dom.Node; 059import org.w3c.dom.NodeList; 060import org.xml.sax.InputSource; 061import org.xml.sax.SAXException; 062 063import javax.xml.parsers.DocumentBuilderFactory; 064import javax.xml.parsers.ParserConfigurationException; 065import java.io.IOException; 066import java.io.StringReader; 067import java.util.ArrayList; 068import java.util.Arrays; 069import java.util.Collection; 070import java.util.Collections; 071import java.util.Comparator; 072import java.util.HashMap; 073import java.util.HashSet; 074import java.util.List; 075import java.util.Map; 076import java.util.Set; 077 078/** 079 * @since 2.1 080 */ 081public 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().findByDocumentId(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 DocumentRouteHeaderValue routeHeaderValue = KEWServiceLocator.getRouteHeaderService(). 319 saveRouteHeader(routeHeader); 320 setRouteHeader(routeHeaderValue); 321 } 322 // we don't have an established mechanism for exposing the action taken that is saved as the result of a (nested) action 323 // so use the last action take saved 324 ActionTakenValue last = getLastActionTaken(getDocumentId()); 325 if (last != null) { 326 notifyAfterActionTaken(last); 327 } 328 } 329 330 /** 331 * Returns the last action taken on a document 332 * @param docId the doc id 333 * @return last action taken on a document 334 */ 335 protected static ActionTakenValue getLastActionTaken(String docId) { 336 ActionTakenValue last = null; 337 Collection<ActionTakenValue> actionsTaken = KEWServiceLocator.getActionTakenService().findByDocumentId(docId); 338 for (ActionTakenValue at: actionsTaken) { 339 if (last == null || at.getActionDate().after(last.getActionDate())) { 340 last = at; 341 } 342 } 343 return last; 344 } 345}