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}