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    }