001/*
002 *  Copyright 2015 Anyware Services
003 *
004 *  Licensed under the Apache 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.apache.org/licenses/LICENSE-2.0
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.ametys.cms.workflow;
017
018import java.util.ArrayList;
019import java.util.Collections;
020import java.util.Comparator;
021import java.util.Date;
022import java.util.HashMap;
023import java.util.HashSet;
024import java.util.Iterator;
025import java.util.List;
026import java.util.Map;
027import java.util.Set;
028
029import javax.jcr.Node;
030import javax.jcr.RepositoryException;
031import javax.jcr.Session;
032
033import org.apache.avalon.framework.parameters.Parameters;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.cocoon.ProcessingException;
037import org.apache.cocoon.acting.ServiceableAction;
038import org.apache.cocoon.environment.ObjectModelHelper;
039import org.apache.cocoon.environment.Redirector;
040import org.apache.cocoon.environment.Request;
041import org.apache.cocoon.environment.SourceResolver;
042import org.apache.commons.lang.StringUtils;
043
044import org.ametys.cms.content.ContentHelper;
045import org.ametys.cms.repository.Content;
046import org.ametys.cms.repository.WorkflowAwareContent;
047import org.ametys.core.cocoon.JSonReader;
048import org.ametys.core.user.UserIdentity;
049import org.ametys.plugins.core.user.UserHelper;
050import org.ametys.plugins.repository.AmetysObjectResolver;
051import org.ametys.plugins.repository.version.VersionAwareAmetysObject;
052import org.ametys.plugins.workflow.store.AbstractJackrabbitWorkflowStore;
053import org.ametys.plugins.workflow.store.AmetysStep;
054import org.ametys.plugins.workflow.support.WorkflowProvider;
055import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
056import org.ametys.runtime.i18n.I18nizableText;
057import org.ametys.runtime.parameter.ParameterHelper;
058
059import com.opensymphony.workflow.loader.ActionDescriptor;
060import com.opensymphony.workflow.loader.StepDescriptor;
061import com.opensymphony.workflow.loader.WorkflowDescriptor;
062import com.opensymphony.workflow.spi.SimpleStep;
063import com.opensymphony.workflow.spi.Step;
064
065/**
066 * This action returns the workflow history of a content
067 *
068 */
069public class ContentHistoryAction extends ServiceableAction
070{
071    private static final I18nizableText __MESSAGE_NO_STEP = new I18nizableText("plugin.cms", "WORKFLOW_UNKNOWN_STEP");
072
073    private static final I18nizableText __MESSAGE_NO_ACTION = new I18nizableText("plugin.cms", "WORKFLOW_UNKNOWN_ACTION");
074
075    private WorkflowProvider _workflowProvider;
076
077    private UserHelper _userHelper;
078
079    private AmetysObjectResolver _resolver;
080
081    private ContentHelper _contentHelper;
082
083    @Override
084    public void service(ServiceManager serviceManager) throws ServiceException
085    {
086        super.service(serviceManager);
087        _workflowProvider = (WorkflowProvider) serviceManager.lookup(WorkflowProvider.ROLE);
088        _userHelper = (UserHelper) serviceManager.lookup(UserHelper.ROLE);
089        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
090        _contentHelper = (ContentHelper) serviceManager.lookup(ContentHelper.ROLE);
091    }
092
093    public Map act(Redirector redirector, SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception
094    {
095        Request request = ObjectModelHelper.getRequest(objectModel);
096
097        String id = request.getParameter("contentId");
098        Content content = _resolver.resolveById(id);
099
100        assert content instanceof VersionAwareAmetysObject;
101
102        Map<String, Object> result = new HashMap<>();
103        List<Map<String, Object>> workflowSteps = new ArrayList<>();
104        
105        Set<String> validatedVersions = new HashSet<>();
106        
107        try
108        {
109            List<VersionInformation> versionsInformation = _resolveVersionInformations((VersionAwareAmetysObject) content);
110            workflowSteps = _getContentWorkflowHistory(content, versionsInformation, validatedVersions);
111        }
112        catch (RepositoryException e)
113        {
114            throw new ProcessingException("Unable to access version history", e);
115        }
116        
117        for (Map<String, Object> stepInfo : workflowSteps)
118        {
119            @SuppressWarnings("unchecked")
120            List<Map<String, Object>> versions = (List<Map<String, Object>>) stepInfo.get("versions");
121            
122            for (Map<String, Object> version : versions)
123            {
124                String versionName = (String) version.get("name");
125                if (validatedVersions.contains(versionName))
126                {
127                    version.put("valid", true);
128                }
129            }
130        }
131
132        result.put("workflow", workflowSteps);
133        request.setAttribute(JSonReader.OBJECT_TO_READ, result);
134        return EMPTY_MAP;
135    }
136
137    private List<Map<String, Object>> _getContentWorkflowHistory(Content content, List<VersionInformation> versionsInformation, Set<String> validatedVersions) throws ProcessingException, RepositoryException
138    {
139        List<Map<String, Object>> workflowHistory = new ArrayList<>();
140
141        if (content instanceof WorkflowAwareContent)
142        {
143            WorkflowAwareContent workflowAwareContent = (WorkflowAwareContent) content;
144            
145            long workflowId = workflowAwareContent.getWorkflowId();
146            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(workflowAwareContent);
147            
148            String workflowName = workflow.getWorkflowName(workflowId);
149
150            if (workflowName == null)
151            {
152                throw new ProcessingException("Unknown workflow name for workflow instance id: " + workflowId);
153            }
154
155            WorkflowDescriptor workflowDescriptor = workflow.getWorkflowDescriptor(workflowName);
156
157            if (workflowDescriptor == null)
158            {
159                throw new ProcessingException("No workflow description for workflow name: " + workflowName);
160            }
161
162            @SuppressWarnings("unchecked")
163            List<Step> allSteps = new ArrayList(workflow.getCurrentSteps(workflowId));
164            allSteps.addAll(workflow.getHistorySteps(workflowId));
165
166            // Sort by start date descendant (only relevant when there is no
167            // split and nor join!)
168            Collections.sort(allSteps, new Comparator<Step>()
169            {
170                public int compare(Step s1, Step s2)
171                {
172                    return -s1.getStartDate().compareTo(s2.getStartDate());
173                }
174            });
175
176            Date startDate = null;
177
178            if (!allSteps.isEmpty())
179            {
180                // Use start date of the first step for the start date
181                // of the unstored "real first step"
182                startDate = allSteps.get(allSteps.size() - 1).getStartDate();
183            }
184
185            // Use a custom step for the unstored "real first step"
186            int initialActionId = (int) _getInitialActionId(workflow, workflowAwareContent);
187            allSteps.add(new SimpleStep(0, 0, 0, initialActionId, null, content.getCreationDate(), startDate, null, "", new long[0], UserIdentity.userIdentityToString(content.getCreator())));
188
189            Iterator<Step> itStep = allSteps.iterator();
190            // We got at least one
191            Step step = itStep.next();
192
193            do
194            {
195                Step previousStep = null;
196
197                if (itStep.hasNext())
198                {
199                    previousStep = itStep.next();
200                }
201
202                workflowHistory.add(_step2Json(content, workflowDescriptor, versionsInformation, validatedVersions, step, previousStep));
203
204                step = previousStep;
205            }
206            while (itStep.hasNext());
207        }
208
209        return workflowHistory;
210    }
211
212    private long _getInitialActionId(AmetysObjectWorkflow workflow, WorkflowAwareContent waContent)
213    {
214        try
215        {
216            Session session = waContent.getNode().getSession();
217            AbstractJackrabbitWorkflowStore workflowStore = (AbstractJackrabbitWorkflowStore) workflow.getConfiguration().getWorkflowStore();
218            Node workflowEntryNode = workflowStore.getEntryNode(session, waContent.getWorkflowId());
219            return workflowEntryNode.getProperty("ametys-internal:initialActionId").getLong();
220        }
221        catch (Exception e)
222        {
223            getLogger().error("Unable to retrieves initial action id for workflow aware content : " + waContent.getId(), e);
224            return 0;
225        }
226    }
227
228    private Map<String, Object> _step2Json(Content content, WorkflowDescriptor workflowDescriptor, List<VersionInformation> versionsInformation, Set<String> validatedVersions, Step step, Step previousStep)
229            throws RepositoryException
230    {
231        Map<String, Object> step2json = new HashMap<>();
232
233        int stepId = step.getStepId();
234        step2json.put("id", stepId);
235        step2json.put("current", step.getFinishDate() == null);
236
237        // We want the caller of the action responsible for being in the step
238        String caller = previousStep != null ? previousStep.getCaller() : null;
239        UserIdentity callerIdentity = UserIdentity.stringToUserIdentity(caller);
240        if (callerIdentity != null)
241        {
242            step2json.put("caller", _userHelper.user2json(callerIdentity));
243        }
244        
245        step2json.putAll(_getDescription2Json(workflowDescriptor, stepId));
246
247        // We want the id of the action responsible for being in the step
248        int actionId = previousStep != null ? previousStep.getActionId() : 0;
249        step2json.putAll(_getWorkfowAction2Json(callerIdentity, content, workflowDescriptor, actionId));
250
251        // Comment
252        if (previousStep != null && previousStep instanceof AmetysStep)
253        {
254            String comments = (String) ((AmetysStep) previousStep).getProperty("comment");
255            if (comments != null)
256            {
257                step2json.put("comment", comments);
258            }
259        }
260
261        /*Date actionStartDate = null;*/
262        Date actionFinishDate = null;
263        if (step instanceof AmetysStep)
264        {
265            /*actionStartDate = (Date) ((AmetysStep) step).getProperty("actionStartDate");*/
266            actionFinishDate = (Date) ((AmetysStep) step).getProperty("actionFinishDate");
267        }
268
269        Date startDate = null;
270        Date finishDate = null;
271        if (actionFinishDate != null)
272        {
273            // New format
274            startDate = step.getStartDate() != null ? step.getStartDate() : null;
275            finishDate = step.getFinishDate() != null ? step.getFinishDate() : null;
276        }
277        else
278        {
279            startDate = previousStep != null ? previousStep.getFinishDate() : null;
280            finishDate = step.getStartDate();
281        }
282
283        boolean isValid = _isValid(step);
284        step2json.put("validation", isValid);
285        step2json.put("date", _getDate(startDate, finishDate, actionFinishDate));
286
287        // Versions
288        List<Map<String, Object>> versions = _getVersionsBetween2Json(versionsInformation, /*stepId, */startDate, finishDate);
289        step2json.put("versions", versions);
290        
291        if (isValid)
292        {
293            for (Map<String, Object> version : versions)
294            {
295                validatedVersions.add((String) version.get("name")); 
296            }
297        }
298
299        return step2json;
300    }
301
302    private Map<String, Object> _getDescription2Json(WorkflowDescriptor workflowDescriptor, int stepId)
303    {
304        Map<String, Object> desc2json = new HashMap<>();
305
306        if (stepId != 0)
307        {
308            StepDescriptor stepDescriptor = workflowDescriptor.getStep(stepId);
309
310            I18nizableText workflowStepName = stepDescriptor == null ? __MESSAGE_NO_STEP : new I18nizableText("application", stepDescriptor.getName());
311            desc2json.put("description", workflowStepName);
312
313            if ("application".equals(workflowStepName.getCatalogue()))
314            {
315                desc2json.put("iconSmall", "/plugins/cms/resources_workflow/" + workflowStepName.getKey() + "-small.png");
316                desc2json.put("iconMedium", "/plugins/cms/resources_workflow/" + workflowStepName.getKey() + "-medium.png");
317                desc2json.put("iconLarge", "/plugins/cms/resources_workflow/" + workflowStepName.getKey() + "-large.png");
318            }
319            else
320            {
321                String pluginName = workflowStepName.getCatalogue().substring("plugin.".length());
322                desc2json.put("iconSmall", "/plugins/" + pluginName + "/resources/img/workflow/" + workflowStepName.getKey() + "-small.png");
323                desc2json.put("iconMedium", "/plugins/" + pluginName + "/resources/img/workflow/" + workflowStepName.getKey() + "-medium.png");
324                desc2json.put("iconLarge", "/plugins/" + pluginName + "/resources/img/workflow/" + workflowStepName.getKey() + "-large.png");
325            }
326        }
327        else
328        {
329            desc2json.put("description", "?");
330            desc2json.put("iconSmall", "/plugins/cms/resources/img/history/workflow/step_0_16.png");
331            desc2json.put("iconMedium", "/plugins/cms/resources/img/history/workflow/step_0_32.png");
332        }
333
334        return desc2json;
335    }
336
337    private Map<String, Object> _getWorkfowAction2Json(UserIdentity caller, Content content, WorkflowDescriptor workflowDescriptor, int actionId)
338    {
339        Map<String, Object> action2json = new HashMap<>();
340
341        Map<String, I18nizableText> i18nParams = new HashMap<>();
342        String userFullName = _userHelper.getUserFullName(caller);
343        i18nParams.put("user", StringUtils.isNotEmpty(userFullName) ? new I18nizableText(_userHelper.getUserFullName(caller)) : new I18nizableText("plugin.cms", "PLUGINS_CMS_UITOOL_HISTORY_UNKNOWN_USER"));
344        i18nParams.put("title", new I18nizableText(_contentHelper.getTitle(content)));
345
346        action2json.put("actionId", actionId);
347        if (actionId != 0)
348        {
349            ActionDescriptor actionDescriptor = workflowDescriptor.getAction(actionId);
350            
351            I18nizableText workflowActionLabel = actionDescriptor == null ? __MESSAGE_NO_ACTION : new I18nizableText("application", actionDescriptor.getName() + "_ACTION_DESCRIPTION", i18nParams);
352            action2json.put("actionLabel", workflowActionLabel);
353            
354            I18nizableText workflowActionName = actionDescriptor == null ? __MESSAGE_NO_ACTION : new I18nizableText("application", actionDescriptor.getName());
355            if ("application".equals(workflowActionName.getCatalogue()))
356            {
357                action2json.put("actionIconSmall", "/plugins/cms/resources_workflow/" + workflowActionName.getKey() + "-small.png");
358                action2json.put("actionIconMedium", "/plugins/cms/resources_workflow/" + workflowActionName.getKey() + "-medium.png");
359                action2json.put("actionIconLarge", "/plugins/cms/resources_workflow/" + workflowActionName.getKey() + "-large.png");
360            }
361            else
362            {
363                String pluginName = workflowActionName.getCatalogue().substring("plugin.".length());
364                action2json.put("actionIconSmall", "/plugins/" + pluginName + "/resources/img/workflow/" + workflowActionName.getKey() + "-small.png");
365                action2json.put("actionIconMedium", "/plugins/" + pluginName + "/resources/img/workflow/" + workflowActionName.getKey() + "-medium.png");
366                action2json.put("actionIconLarge", "/plugins/" + pluginName + "/resources/img/workflow/" + workflowActionName.getKey() + "-large.png");
367            }
368        }
369        else
370        {
371            action2json.put("actionLabel", new I18nizableText("plugin.cms", "WORKFLOW_ACTION_CREATE_ACTION_DESCRIPTION", i18nParams));
372            action2json.put("actionIconSmall", "/plugins/cms/resources/img/history/workflow/action_0_16.png");
373            action2json.put("actionIconMedium", "/plugins/cms/resources/img/history/workflow/action_0_32.png");
374            action2json.put("actionIconLarge", "/plugins/cms/resources/img/history/workflow/action_0_32.png");
375        }
376
377        return action2json;
378    }
379
380    private List<Map<String, Object>> _getVersionsBetween2Json(List<VersionInformation> versionsInformation, /*int stepId, */Date startDate, Date finishDate) throws RepositoryException
381    {
382        List<Map<String, Object>> versions = new ArrayList<>();
383        List<VersionInformation> versionsInsideStep = _computeVersionsBetween(versionsInformation, startDate, finishDate);
384
385        for (VersionInformation versionInformation : versionsInsideStep)
386        {
387            Map<String, Object> version = new HashMap<>();
388
389            String versionName = versionInformation.getVersionName();
390            version.put("label", versionInformation.getLabels());
391            version.put("name", versionName);
392            if (StringUtils.isNotEmpty(versionInformation.getVersionRawName()))
393            {
394                version.put("rawName", versionInformation.getVersionRawName());
395            }
396
397            version.put("createdAt", ParameterHelper.valueToString(versionInformation.getCreatedAt()));
398
399            versions.add(version);
400        }
401        return versions;
402    }
403
404    private boolean _isValid(Step step)
405    {
406        Boolean validation = false;
407        if (step instanceof AmetysStep)
408        {
409            validation = (Boolean) ((AmetysStep) step).getProperty("validation");
410        }
411
412        return validation == null ? false : validation;
413    }
414    
415    private String _getDate(Date startDate, Date finishDate, Date actionFinishDate)
416    {
417        if (actionFinishDate != null)
418        {
419            return ParameterHelper.valueToString(startDate);
420        }
421        else
422        {
423            return ParameterHelper.valueToString(finishDate);
424        }
425    }
426
427    private List<VersionInformation> _computeVersionsBetween(List<VersionInformation> versionsInformation, Date startDate, Date finishDate) throws RepositoryException
428    {
429        List<VersionInformation> versionsInsideStep = new ArrayList<>();
430
431        for (VersionInformation versionInformation : versionsInformation)
432        {
433            Date versionCreationDate = versionInformation.getCreatedAt();
434
435            if (startDate != null)
436            {
437                if (versionCreationDate.after(startDate))
438                {
439                    if (finishDate == null || versionCreationDate.before(finishDate))
440                    {
441                        versionsInsideStep.add(versionInformation);
442                    }
443                }
444            }
445            else
446            {
447                if (finishDate == null || versionCreationDate.before(finishDate))
448                {
449                    versionsInsideStep.add(versionInformation);
450                }
451            }
452        }
453
454        // If there is no version created inside the step,
455        // retrieve the last one just before the step
456        if (versionsInsideStep.isEmpty())
457        {
458            VersionInformation lastVersionBeforeStep = null;
459
460            for (VersionInformation versionInformation : versionsInformation)
461            {
462                Date versionCreationDate = versionInformation.getCreatedAt();
463
464                if (startDate != null && versionCreationDate.before(startDate))
465                {
466                    lastVersionBeforeStep = versionInformation;
467                    break;
468                }
469                else if (startDate == null)
470                {
471                    // Use the first version
472                    lastVersionBeforeStep = versionInformation;
473                }
474            }
475
476            if (lastVersionBeforeStep != null)
477            {
478                versionsInsideStep.add(lastVersionBeforeStep);
479            }
480        }
481
482        // if there is still no version for this step, then it is version 1
483        // (case of migrations)
484        if (versionsInsideStep.isEmpty())
485        {
486            versionsInsideStep.add(new VersionInformation("1"));
487        }
488
489        return versionsInsideStep;
490    }
491
492    private List<VersionInformation> _resolveVersionInformations(VersionAwareAmetysObject content) throws RepositoryException
493    {
494        List<VersionInformation> versionsInformation = new ArrayList<>();
495
496        for (String revision : content.getAllRevisions())
497        {
498            VersionInformation versionInformation = new VersionInformation(revision, content.getRevisionTimestamp(revision));
499
500            for (String label : content.getLabels(revision))
501            {
502                versionInformation.addLabel(label);
503            }
504
505            versionsInformation.add(versionInformation);
506        }
507
508        // Sort by date descendant
509        Collections.sort(versionsInformation, new Comparator<VersionInformation>()
510        {
511            public int compare(VersionInformation o1, VersionInformation o2)
512            {
513                try
514                {
515                    return -o1.getCreatedAt().compareTo(o2.getCreatedAt());
516                }
517                catch (RepositoryException e)
518                {
519                    throw new RuntimeException("Unable to retrieve a creation date", e);
520                }
521            }
522        });
523
524        // Set the version name
525        int count = versionsInformation.size();
526        for (VersionInformation versionInformation : versionsInformation)
527        {
528            versionInformation.setVersionName(String.valueOf(count--));
529        }
530
531        return versionsInformation;
532    }
533
534    private static class VersionInformation
535    {
536        private String _rawName;
537
538        private String _name;
539
540        private Date _creationDate;
541
542        private Set<String> _labels = new HashSet<>();
543
544        /**
545         * Creates a {@link VersionInformation}.
546         * 
547         * @param rawName the revision name.
548         * @param creationDate the revision creation date.
549         * @throws RepositoryException if an error occurs.
550         */
551        public VersionInformation(String rawName, Date creationDate) throws RepositoryException
552        {
553            _creationDate = creationDate;
554            _rawName = rawName;
555        }
556
557        /**
558         * Creates a named {@link VersionInformation}.
559         * 
560         * @param name the name to use.
561         */
562        public VersionInformation(String name)
563        {
564            _name = name;
565        }
566
567        /**
568         * Set the version name
569         * 
570         * @param name The name
571         */
572        public void setVersionName(String name)
573        {
574            _name = name;
575        }
576
577        /**
578         * Retrieves the version name.
579         * 
580         * @return the version name.
581         */
582        public String getVersionName()
583        {
584            return _name;
585        }
586
587        /**
588         * Retrieves the version raw name.
589         * 
590         * @return the version raw name.
591         */
592        public String getVersionRawName()
593        {
594            return _rawName;
595        }
596
597        /**
598         * Retrieves the creation date.
599         * 
600         * @return the creation date.
601         * @throws RepositoryException if an error occurs.
602         */
603        public Date getCreatedAt() throws RepositoryException
604        {
605            return _creationDate;
606        }
607
608        /**
609         * Retrieves the labels associated with this version.
610         * 
611         * @return the labels.
612         */
613        public Set<String> getLabels()
614        {
615            return _labels;
616        }
617
618        /**
619         * Add a label to this version.
620         * 
621         * @param label the label.
622         */
623        public void addLabel(String label)
624        {
625            _labels.add(label);
626        }
627    }
628}