001/*
002 *  Copyright 2017 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.odf.workflow;
017
018import java.util.ArrayList;
019import java.util.Collection;
020import java.util.Comparator;
021import java.util.HashMap;
022import java.util.HashSet;
023import java.util.List;
024import java.util.Map;
025import java.util.Set;
026import java.util.TreeSet;
027import java.util.stream.Collectors;
028
029import org.apache.avalon.framework.component.Component;
030import org.apache.avalon.framework.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.avalon.framework.service.Serviceable;
033import org.apache.commons.lang3.ArrayUtils;
034import org.apache.commons.lang3.StringUtils;
035
036import org.ametys.cms.CmsConstants;
037import org.ametys.cms.repository.Content;
038import org.ametys.cms.repository.WorkflowAwareContent;
039import org.ametys.cms.workflow.ContentWorkflowHelper;
040import org.ametys.core.ui.Callable;
041import org.ametys.odf.ODFHelper;
042import org.ametys.odf.ProgramItem;
043import org.ametys.odf.course.Course;
044import org.ametys.odf.course.CourseFactory;
045import org.ametys.odf.courselist.CourseListFactory;
046import org.ametys.odf.orgunit.OrgUnit;
047import org.ametys.odf.orgunit.OrgUnitFactory;
048import org.ametys.odf.person.PersonFactory;
049import org.ametys.odf.program.AbstractProgram;
050import org.ametys.odf.program.ContainerFactory;
051import org.ametys.odf.program.ProgramFactory;
052import org.ametys.odf.program.SubProgramFactory;
053import org.ametys.plugins.repository.AmetysObjectResolver;
054import org.ametys.plugins.repository.UnknownAmetysObjectException;
055import org.ametys.plugins.repository.version.VersionAwareAmetysObject;
056import org.ametys.plugins.workflow.support.WorkflowProvider;
057import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
058import org.ametys.runtime.plugin.component.AbstractLogEnabled;
059
060import com.opensymphony.workflow.InvalidActionException;
061import com.opensymphony.workflow.WorkflowException;
062import com.opensymphony.workflow.spi.Step;
063
064/**
065 * Helper for ODF contents on their workflow
066 *
067 */
068public class ODFWorkflowHelper extends AbstractLogEnabled implements Component, Serviceable
069{
070    /** The component role. */
071    public static final String ROLE = ODFWorkflowHelper.class.getName();
072    
073    /** The validate step id */
074    public static final int VALIDATED_STEP_ID = 3;
075    
076    /** The action id of global validation */
077    public static final int VALIDATE_ACTION_ID = 4;
078    
079    /** The action id of global unpublishment */
080    public static final int UNPUBLISH_ACTION_ID = 10;
081    
082    /** Constant for storing the result map into the transient variables map. */
083    protected static final String CONTENTS_IN_ERROR_KEY = "contentsInError";
084    
085    /** Constant for storing the result map into the transient variables map. */
086    protected static final String VALIDATED_CONTENTS_KEY = "validatedContents";
087    
088    /** Constant for storing the unpublish result map into the transient variables map. */
089    protected static final String UNPUBLISHED_CONTENTS_KEY = "unpublishedContents";
090    
091    /** The Ametys object resolver */
092    protected AmetysObjectResolver _resolver;
093    /** The workflow provider */
094    protected WorkflowProvider _workflowProvider;
095    /** The ODF helper */
096    protected ODFHelper _odfHelper;
097    /** The workflow helper for contents */
098    protected ContentWorkflowHelper _contentWorkflowHelper;
099    
100    @Override
101    public void service(ServiceManager manager) throws ServiceException
102    {
103        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
104        _workflowProvider = (WorkflowProvider) manager.lookup(WorkflowProvider.ROLE);
105        _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE);
106        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
107    }
108    
109    /**
110     * Check if the contents has referenced contents that are not already validated (children excluded)
111     * @param contentIds The id of contents to check
112     * @return A map with success key to true if referenced contents are validated. A map with the invalidated contents otherwise.
113     */
114    @Callable
115    public Map<String, Object> checkReferences(List<String> contentIds)
116    {
117        Map<String, Object> result = new HashMap<>();
118        
119        List<Map<String, Object>> contentsInError = new ArrayList<>();
120        
121        for (String contentId : contentIds)
122        {
123            Set<Content> invalidatedContents = new TreeSet<>(new ContentTypeComparator());
124            
125            WorkflowAwareContent content = _resolver.resolveById(contentId);
126            _checkValidateStep(content, invalidatedContents, false);
127            
128            // Remove initial content from invalidated contents
129            invalidatedContents.remove(content);
130            
131            if (!invalidatedContents.isEmpty())
132            {
133                List<Map<String, Object>> invalidatedContentsAsJson = invalidatedContents.stream()
134                        .map(c -> _content2Json(c))
135                        .collect(Collectors.toList());
136                
137                Map<String, Object> contentInError = new HashMap<>();
138                contentInError.put("id", content.getId());
139                contentInError.put("code", ((ProgramItem) content).getCode());
140                contentInError.put("title", content.getTitle());
141                contentInError.put("invalidatedContents", invalidatedContentsAsJson);
142                contentsInError.add(contentInError);
143            }
144        }
145        
146        result.put("contentsInError", contentsInError);
147        result.put("success", contentsInError.isEmpty());
148        return result;
149    }
150    
151    /**
152     * Get the global validation status of a content
153     * @param contentId the id of content
154     * @return the result
155     */
156    @Callable
157    public Map<String, Object> getGlobalValidationStatus(String contentId)
158    {
159        Map<String, Object> result = new HashMap<>();
160        
161        WorkflowAwareContent waContent = _resolver.resolveById(contentId);
162        
163        // Order invalidated contents by types
164        Set<Content> invalidatedContents = getInvalidatedContents(waContent);
165        
166        List<Map<String, Object>> invalidatedContentsAsJson = invalidatedContents.stream()
167                .map(c -> _content2Json(c))
168                .collect(Collectors.toList());
169        
170        result.put("invalidatedContents", invalidatedContentsAsJson);
171        result.put("globalValidated", invalidatedContents.isEmpty());
172        
173        return result;
174    }
175    
176    /**
177     * Get the invalidated contents referenced by a ODF content
178     * @param content the initial ODF content
179     * @return the set of referenced invalidated contents
180     */
181    public Set<Content> getInvalidatedContents(WorkflowAwareContent content)
182    {
183        Set<Content> invalidatedContents = new TreeSet<>(new ContentTypeComparator());
184        
185        _checkValidateStep(content, invalidatedContents, true);
186        
187        return invalidatedContents;
188    }
189    
190    /**
191     * Determines if a content is already in validated step
192     * @param content The content to test
193     * @return true if the content is already validated
194     */
195    public boolean isInValidatedStep (WorkflowAwareContent content)
196    {
197        AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(content);
198        long workflowId = content.getWorkflowId();
199        
200        List<Step> steps = workflow.getCurrentSteps(workflowId);
201        for (Step step : steps)
202        {
203            if (step.getStepId() == VALIDATED_STEP_ID)
204            {
205                return true;
206            }
207        }
208        
209        return false;
210    }
211    
212    private void _checkValidateStep (WorkflowAwareContent content, Set<Content> invalidatedContents, boolean checkChildren)
213    {
214        if (!isInValidatedStep(content))
215        {
216            invalidatedContents.add(content);
217        }
218        
219        if (checkChildren && content instanceof ProgramItem)
220        {
221            // Check the structure recursively
222            List<ProgramItem> children = _odfHelper.getChildProgramItems((ProgramItem) content)
223                    .stream()
224                    .filter(ProgramItem::isPublishable)
225                    .toList();
226            for (ProgramItem child : children)
227            {
228                WorkflowAwareContent waChild = (WorkflowAwareContent) child;
229                _checkValidateStep(waChild, invalidatedContents, checkChildren);
230            }
231        }
232        
233        // Validate others referenced contents
234        if (content instanceof AbstractProgram)
235        {
236            _checkReferencedContents(((AbstractProgram) content).getOrgUnits(), invalidatedContents, checkChildren);
237            _checkReferencedContents(((AbstractProgram) content).getContacts(), invalidatedContents, checkChildren);
238        }
239        else if (content instanceof Course)
240        {
241            _checkReferencedContents(((Course) content).getOrgUnits(), invalidatedContents, checkChildren);
242            _checkReferencedContents(((Course) content).getContacts(), invalidatedContents, checkChildren);
243        }
244        else if (content instanceof OrgUnit)
245        {
246            _checkReferencedContents(((OrgUnit) content).getContacts(), invalidatedContents, checkChildren);
247        }
248    }
249    
250    private void _checkReferencedContents(Collection<String> refContentIds, Set<Content> invalidatedContents, boolean recursively)
251    {
252        for (String id : refContentIds)
253        {
254            try
255            {
256                if (StringUtils.isNotEmpty(id))
257                {
258                    WorkflowAwareContent refContent = _resolver.resolveById(id);
259                    if (recursively)
260                    {
261                        _checkValidateStep(refContent, invalidatedContents, recursively);
262                    }
263                    else if (!isInValidatedStep(refContent))
264                    {
265                        invalidatedContents.add(refContent);
266                    }
267                }
268            }
269            catch (UnknownAmetysObjectException e)
270            {
271                // Nothing
272            }
273        }
274    }
275    
276    /**
277     * Global validation on a contents.
278     * Validate the contents with their whole structure and the others referenced contacts and orgunits.
279     * @param contentIds the id of contents to validation recursively
280     * @return the result for each initial contents
281     */
282    @Callable
283    public Map<String, Object> globalValidate(List<String> contentIds)
284    {
285        Map<String, Object> result = new HashMap<>();
286        
287        for (String contentId : contentIds)
288        {
289            Map<String, Object> contentResult = new HashMap<>();
290            
291            contentResult.put(CONTENTS_IN_ERROR_KEY, new HashSet<Content>());
292            contentResult.put(VALIDATED_CONTENTS_KEY, new HashSet<String>());
293            
294            ProgramItem programItem = _resolver.resolveById(contentId);
295            if (programItem.isPublishable())
296            {
297                _validateRecursively((WorkflowAwareContent) programItem, contentResult);
298            }
299            
300            @SuppressWarnings("unchecked")
301            Set<Content> contentsInError = (Set<Content>) contentResult.get(CONTENTS_IN_ERROR_KEY);
302            List<Map<String, Object>> contentsInErrorAsJson = contentsInError.stream()
303                    .map(c -> _content2Json(c))
304                    .collect(Collectors.toList());
305            
306            contentResult.put(CONTENTS_IN_ERROR_KEY, contentsInErrorAsJson);
307            
308            result.put(contentId, contentResult);
309        }
310        
311        return result;
312    }
313    
314    /**
315     * Get the JSON representation of the content
316     * @param content the content
317     * @return the content properties
318     */
319    protected Map<String, Object> _content2Json(Content content)
320    {
321        Map<String, Object> content2json = new HashMap<>();
322        content2json.put("title", content.getTitle());
323        content2json.put("id", content.getId());
324        
325        if (content instanceof ProgramItem)
326        {
327            content2json.put("code", ((ProgramItem) content).getCode());
328        }
329        else if (content instanceof OrgUnit)
330        {
331            content2json.put("code", ((OrgUnit) content).getUAICode());
332        }
333        
334        return content2json;
335    }
336    
337    /**
338     * Validate the referenced contents recursively
339     * @param content The validated content
340     * @param result the result object to fill during process
341     */
342    protected void _validateRecursively (WorkflowAwareContent content, Map<String, Object> result)
343    {
344        @SuppressWarnings("unchecked")
345        Set<String> validatedContentIds = (Set<String>) result.get(VALIDATED_CONTENTS_KEY);
346        @SuppressWarnings("unchecked")
347        Set<Content> contentsInError = (Set<Content>) result.get(CONTENTS_IN_ERROR_KEY);
348        
349        if (!isInValidatedStep(content))
350        {
351            // Validate content itself
352            if (!_doValidateWorkflowAction(content, VALIDATE_ACTION_ID))
353            {
354                contentsInError.add(content);
355            }
356            else
357            {
358                validatedContentIds.add(content.getId());
359            }
360        }
361        
362        if (content instanceof ProgramItem)
363        {
364            // Validate the structure recursively
365            List<ProgramItem> children = _odfHelper.getChildProgramItems((ProgramItem) content)
366                    .stream()
367                    .filter(ProgramItem::isPublishable)
368                    .toList();
369            for (ProgramItem child : children)
370            {
371                _validateRecursively((WorkflowAwareContent) child, result);
372            }
373        }
374        
375        // Validate others referenced contents
376        if (content instanceof AbstractProgram)
377        {
378            _validateReferencedContents(((AbstractProgram) content).getOrgUnits(), result);
379            _validateReferencedContents(((AbstractProgram) content).getContacts(), result);
380        }
381        else if (content instanceof Course)
382        {
383            _validateReferencedContents(((Course) content).getOrgUnits(), result);
384            _validateReferencedContents(((Course) content).getContacts(), result);
385        }
386        else if (content instanceof OrgUnit)
387        {
388            _validateReferencedContents(((OrgUnit) content).getContacts(), result);
389        }
390    }
391    
392    /**
393     * Validate the list of referenced contents
394     * @param refContentIds The id of contents to validate
395     * @param result the result object to fill during process
396     */
397    protected void _validateReferencedContents (Collection<String> refContentIds, Map<String, Object> result)
398    {
399        @SuppressWarnings("unchecked")
400        Set<String> validatedContentIds = (Set<String>) result.get(VALIDATED_CONTENTS_KEY);
401        @SuppressWarnings("unchecked")
402        Set<Content> contentsInError = (Set<Content>) result.get(CONTENTS_IN_ERROR_KEY);
403        
404        for (String id : refContentIds)
405        {
406            try
407            {
408                if (StringUtils.isNotEmpty(id))
409                {
410                    WorkflowAwareContent content = _resolver.resolveById(id);
411                    if (!isInValidatedStep(content))
412                    {
413                        if (!_doValidateWorkflowAction (content, VALIDATE_ACTION_ID))
414                        {
415                            contentsInError.add(content);
416                        }
417                        else
418                        {
419                            validatedContentIds.add(content.getId());
420                        }
421                    }
422                }
423            }
424            catch (UnknownAmetysObjectException e)
425            {
426                // Nothing
427            }
428        }
429    }
430    
431    /**
432     * Validate a content
433     * @param content The content to validate
434     * @param actionId The id of validate action
435     * @return true if the validation success
436     */
437    protected boolean _doValidateWorkflowAction (WorkflowAwareContent content, int actionId)
438    {
439        try
440        {
441            _contentWorkflowHelper.doAction(content, actionId, new HashMap<>());
442            return true;
443        }
444        catch (InvalidActionException e)
445        {
446            getLogger().warn("Unable to validate content \"{}\" ({}): mandatory metadata are probably missing or the content is locked", content.getTitle(), content.getId(), e);
447            return false;
448        }
449        catch (WorkflowException e)
450        {
451            getLogger().warn("Failed to validate content \"{}\" ({})", content.getTitle(), content.getId(), e);
452            return false;
453        }
454    }
455    
456    /**
457     * Set the publishable state of contents
458     * @param contentIds The id of contents
459     * @param isPublishable <code>true</code> to set content as publishable, <code>false</code> otherwise
460     * @return The result map
461     */
462    @Callable
463    public Map<String, Object> setPublishableState (List<String> contentIds, boolean isPublishable)
464    {
465        Map<String, Object> result = new HashMap<>();
466        
467        for (String id : contentIds)
468        {
469            Map<String, Object> contentResult = new HashMap<>();
470            Set<Content> contentsInError = new HashSet<>();
471            contentResult.put(CONTENTS_IN_ERROR_KEY, contentsInError);
472            contentResult.put(UNPUBLISHED_CONTENTS_KEY, new HashSet<String>());
473            
474            WorkflowAwareContent content = _resolver.resolveById(id);
475            if (content instanceof ProgramItem programItem)
476            {
477                try
478                {
479                    programItem.setPublishable(isPublishable);
480                    content.saveChanges();
481
482                    if (!isPublishable)
483                    {
484                        _unpublishRecursively(programItem, contentResult);
485                    }
486                }
487                catch (Exception e)
488                {
489                    getLogger().error("Unable to set publishable property for content '{}' with id '{}'", content.getTitle(), content.getId(), e);
490                    contentsInError.add(content);
491                }
492
493                List<Map<String, Object>> contentsInErrorAsJson = contentsInError.stream()
494                        .map(c -> _content2Json(c))
495                        .collect(Collectors.toList());
496                contentResult.put(CONTENTS_IN_ERROR_KEY, contentsInErrorAsJson);
497                
498                result.put(id, contentResult);
499            }
500        } 
501        
502        return result;
503    }
504    
505    /**
506     * Unpublish the referenced contents recursively
507     * @param programItem The content to unpublish
508     * @param result the result object to fill during process
509     */
510    protected void _unpublishRecursively (ProgramItem programItem, Map<String, Object> result)
511    {
512        @SuppressWarnings("unchecked")
513        Set<String> unpublishedContentIds = (Set<String>) result.get(UNPUBLISHED_CONTENTS_KEY);
514        @SuppressWarnings("unchecked")
515        Set<Content> contentsInError = (Set<Content>) result.get(CONTENTS_IN_ERROR_KEY);
516        
517        if (_isPublished(programItem))
518        {
519            // Unpublish content itself
520            if (!_doUnpublishWorkflowAction((WorkflowAwareContent) programItem, UNPUBLISH_ACTION_ID))
521            {
522                contentsInError.add((Content) programItem);
523            }
524            else
525            {
526                unpublishedContentIds.add(programItem.getId());
527            }
528        }
529
530        // Unpublish the structure recursively
531        List<ProgramItem> children = _odfHelper.getChildProgramItems(programItem);
532        for (ProgramItem child : children)
533        {
534            boolean hasOtherPublishedParent = _odfHelper.getParentProgramItems(child)
535                .stream()
536                .filter(p -> !p.getId().equals(programItem.getId()))
537                .filter(this::_isPublished)
538                .findFirst()
539                .isPresent();
540                
541            // Don't unpublish the content if it has an other published parent
542            if (!hasOtherPublishedParent)
543            {
544                _unpublishRecursively(child, result);
545            }
546        }
547    }
548    
549    /**
550     * Unpublish a content
551     * @param content The content to unpublish
552     * @param actionId The id of unpublish action
553     * @return true if the unpublish success
554     */
555    protected boolean _doUnpublishWorkflowAction (WorkflowAwareContent content, int actionId)
556    {
557        try
558        {
559            _contentWorkflowHelper.doAction(content, actionId, new HashMap<>());
560            return true;
561        }
562        catch (Exception e)
563        {
564            getLogger().warn("Failed to unpublish content \"{}\" ({})", content.getTitle(), content.getId(), e);
565            return false;
566        }
567    }
568    
569    /**
570     * <code>true</code> if the parent is publishable
571     * @param content the content
572     * @return <code>true</code> if the parent is publishable
573     */
574    public boolean isParentPublishable(ProgramItem content)
575    {
576        List<ProgramItem> parents = _odfHelper.getParentProgramItems(content);
577        if (parents.isEmpty())
578        {
579            return true;
580        }
581        
582        return parents.stream()
583                .filter(c -> c.isPublishable() && isParentPublishable(c))
584                .findAny()
585                .isPresent();
586    }
587    
588    private boolean _isPublished(ProgramItem content)
589    {
590        return content instanceof VersionAwareAmetysObject versionAAO ? ArrayUtils.contains(versionAAO.getAllLabels(), CmsConstants.LIVE_LABEL) : false;
591    }
592    
593    class ContentTypeComparator implements Comparator<Content>
594    {
595        String[] _orderedContentTypes = new String[] {
596            ProgramFactory.PROGRAM_CONTENT_TYPE,
597            SubProgramFactory.SUBPROGRAM_CONTENT_TYPE,
598            ContainerFactory.CONTAINER_CONTENT_TYPE,
599            CourseListFactory.COURSE_LIST_CONTENT_TYPE,
600            CourseFactory.COURSE_CONTENT_TYPE,
601            OrgUnitFactory.ORGUNIT_CONTENT_TYPE,
602            PersonFactory.PERSON_CONTENT_TYPE
603        };
604                
605        @Override
606        public int compare(Content c1, Content c2)
607        {
608            if (c1 == c2)
609            {
610                return 0;
611            }
612            
613            String cTypeId1 = c1.getTypes()[0];
614            String cTypeId2 = c2.getTypes()[0];
615            
616            int i1 = ArrayUtils.indexOf(_orderedContentTypes, cTypeId1);
617            int i2 = ArrayUtils.indexOf(_orderedContentTypes, cTypeId2);
618            
619            if (i1 == i2)
620            {
621                // order by title for content of same type
622                int compareTo = c1.getTitle().compareTo(c2.getTitle());
623                if (compareTo == 0)
624                {
625                    // for content of same title, order by id to do not return 0 to add it in TreeSet
626                    // Indeed, in a TreeSet implementation two elements that are equal by the method compareTo are, from the standpoint of the set, equal 
627                    return c1.getId().compareTo(c2.getId());
628                }
629                else
630                {
631                    return compareTo;
632                }
633            }
634            
635            return i1 != -1 && i1 < i2 ? -1 : 1;
636        }
637    }
638    
639}