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