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