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