001/*
002 *  Copyright 2018 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.helper;
017
018import java.util.ArrayList;
019import java.util.Collections;
020import java.util.HashMap;
021import java.util.HashSet;
022import java.util.List;
023import java.util.Map;
024import java.util.Map.Entry;
025import java.util.Set;
026import java.util.stream.Collectors;
027
028import org.apache.avalon.framework.component.Component;
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.avalon.framework.service.Serviceable;
032import org.apache.commons.lang3.StringUtils;
033import org.apache.commons.lang3.tuple.Pair;
034
035import org.ametys.cms.ObservationConstants;
036import org.ametys.cms.clientsideelement.content.SmartContentClientSideElementHelper;
037import org.ametys.cms.content.ContentHelper;
038import org.ametys.cms.data.ContentDataHelper;
039import org.ametys.cms.indexing.solr.SolrIndexHelper;
040import org.ametys.cms.repository.Content;
041import org.ametys.cms.repository.ModifiableWorkflowAwareContent;
042import org.ametys.cms.repository.WorkflowAwareContent;
043import org.ametys.cms.workflow.ContentWorkflowHelper;
044import org.ametys.core.observation.Event;
045import org.ametys.core.observation.ObservationManager;
046import org.ametys.core.right.RightManager;
047import org.ametys.core.right.RightManager.RightResult;
048import org.ametys.core.user.CurrentUserProvider;
049import org.ametys.odf.ODFHelper;
050import org.ametys.odf.ProgramItem;
051import org.ametys.odf.course.Course;
052import org.ametys.odf.course.CourseFactory;
053import org.ametys.odf.course.ShareableCourseConstants;
054import org.ametys.odf.courselist.CourseList;
055import org.ametys.odf.coursepart.CoursePart;
056import org.ametys.odf.observation.OdfObservationConstants;
057import org.ametys.odf.orgunit.OrgUnit;
058import org.ametys.odf.person.Person;
059import org.ametys.odf.program.Container;
060import org.ametys.odf.program.Program;
061import org.ametys.odf.program.ProgramPart;
062import org.ametys.odf.program.SubProgram;
063import org.ametys.odf.program.TraversableProgramPart;
064import org.ametys.plugins.repository.AmetysObjectIterator;
065import org.ametys.plugins.repository.AmetysObjectResolver;
066import org.ametys.plugins.repository.AmetysRepositoryException;
067import org.ametys.plugins.repository.ModifiableAmetysObject;
068import org.ametys.plugins.repository.RemovableAmetysObject;
069import org.ametys.plugins.repository.lock.LockableAmetysObject;
070import org.ametys.plugins.repository.query.QueryHelper;
071import org.ametys.plugins.repository.query.expression.Expression.Operator;
072import org.ametys.plugins.repository.query.expression.StringExpression;
073import org.ametys.runtime.plugin.component.AbstractLogEnabled;
074
075import com.opensymphony.workflow.WorkflowException;
076
077/**
078 * Helper to delete an ODF content.
079 */
080public class DeleteODFContentHelper extends AbstractLogEnabled implements Component, Serviceable
081{
082    /** Avalon role. */
083    public static final String ROLE = DeleteODFContentHelper.class.getName();
084    
085    /** Ametys object resolver */
086    private AmetysObjectResolver _resolver;
087    
088    /** The ODF helper */
089    private ODFHelper _odfHelper;
090    
091    /** Observer manager. */
092    private ObservationManager _observationManager;
093    
094    /** The Content workflow helper */
095    private ContentWorkflowHelper _contentWorkflowHelper;
096    
097    /** The current user provider */
098    private CurrentUserProvider _currentUserProvider;
099    
100    /** The rights manager */
101    private RightManager _rightManager;
102
103    /** The content helper */
104    private ContentHelper _contentHelper;
105    
106    /** Helper for smart content client elements */
107    private SmartContentClientSideElementHelper _smartHelper;
108
109    private SolrIndexHelper _solrIndexHelper;
110    
111    public void service(ServiceManager manager) throws ServiceException
112    {
113        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
114        _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE);
115        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
116        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
117        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
118        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
119        _smartHelper = (SmartContentClientSideElementHelper) manager.lookup(SmartContentClientSideElementHelper.ROLE);
120        _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE);
121        _solrIndexHelper = (SolrIndexHelper) manager.lookup(SolrIndexHelper.ROLE);
122    }
123    
124    /**
125     * Enumeration for the mode of deletion
126     *
127     */
128    public enum DeleteMode
129    {
130        /** Delete the content only */
131        SINGLE,
132        /** Delete the content and its structure */
133        STRUCTURE_ONLY,
134        /** Delete the content and its structure and its courses */
135        FULL
136    }
137    
138    /**
139     * Delete ODF contents
140     * @param contentsId The ids of contents to delete
141     * @param modeParam The mode of deletion
142     * @return the deleted and undeleted contents
143     */
144    public Map<String, Object> deleteContents(List<String> contentsId, String modeParam)
145    {
146        return deleteContents(contentsId, modeParam, false);
147    }
148    
149    /**
150     * Delete ODF contents
151     * @param contentsId The ids of contents to delete
152     * @param modeParam The mode of deletion
153     * @param ignoreRights If true, bypass the rights check during the deletion
154     * @return the deleted and undeleted contents
155     */
156    public Map<String, Object> deleteContents(List<String> contentsId, String modeParam, boolean ignoreRights)
157    {
158        Map<String, Object> results = new HashMap<>();
159        
160        List<String> alreadyDeletedContentIds = new ArrayList<>();
161        for (String contentId : contentsId)
162        {
163            Map<String, Object> result = new HashMap<>();
164            result.put("deleted-contents", new HashSet<>());
165            result.put("undeleted-contents", new HashSet<>());
166            result.put("referenced-contents", new HashSet<>());
167            result.put("unauthorized-contents", new HashSet<>());
168            result.put("locked-contents", new HashSet<>());
169            result.put("hierarchy-changed-contents", new HashMap<>());
170
171            if (!alreadyDeletedContentIds.contains(contentId))
172            {
173                Content content = _resolver.resolveById(contentId);
174                
175                result.put("initial-content", content.getId());
176                
177                DeleteMode deleteMode = StringUtils.isNotBlank(modeParam) ? DeleteMode.valueOf(modeParam.toUpperCase()) : DeleteMode.SINGLE;
178                
179                boolean referenced = isContentReferenced(content);
180                if (referenced || !_checkBeforeDeletion(content, deleteMode, ignoreRights, result))
181                {
182                    if (referenced)
183                    {
184                        // Indicate that the content is referenced.
185                        @SuppressWarnings("unchecked")
186                        Set<Content> referencedContents = (Set<Content>) result.get("referenced-contents");
187                        referencedContents.add(content);
188                    }
189                    result.put("check-before-deletion-failed", true);
190                }
191                else
192                {
193                    // Process deletion
194                    _deleteContent(content, deleteMode, ignoreRights, result);
195                    
196                    @SuppressWarnings("unchecked")
197                    Set<String> deletedContents = (Set<String>) result.get("deleted-contents");
198                    if (deletedContents != null)
199                    {
200                        alreadyDeletedContentIds.addAll(deletedContents);
201                    }
202                }
203            }
204            
205            results.put(contentId, result);
206        }
207
208        return results;
209    }
210    
211    /**
212     * Delete ODF contents
213     * @param contentsId The ids of contents to delete
214     * @param modeParam The mode of deletion
215     * @return the deleted and undeleted contents
216     */
217    public Map<String, Object> deleteContentsWithLog(List<String> contentsId, String modeParam)
218    {
219        return deleteContentsWithLog(contentsId, modeParam, false);
220    }
221    
222    /**
223     * Delete ODF contents
224     * @param contentsId The ids of contents to delete
225     * @param modeParam The mode of deletion
226     * @param ignoreRights If true, bypass the rights check during the deletion
227     * @return the deleted and undeleted contents
228     */
229    @SuppressWarnings("unchecked")
230    public Map<String, Object> deleteContentsWithLog(List<String> contentsId, String modeParam, boolean ignoreRights)
231    {
232        Map<String, Object> results = deleteContents(contentsId, modeParam, ignoreRights);
233        for (String contentId : contentsId)
234        {
235            Map<String, Object> result = (Map<String, Object>) results.get(contentId);
236            
237            Set<Content> referencedContents = (Set<Content>) result.get("referenced-contents");
238            if (referencedContents.size() > 0)
239            {
240                getLogger().info("The following contents cannot be deleted because they are referenced: {}", referencedContents.stream().map(m -> m.getId()).collect(Collectors.toList()));
241            }
242            
243            Set<Content> lockedContents = (Set<Content>) result.get("locked-contents");
244            if (lockedContents.size() > 0)
245            {
246                getLogger().info("The following contents cannot be deleted because they are locked: {}", lockedContents.stream().map(m -> m.getId()).collect(Collectors.toList()));
247            }
248            
249            Set<Content> unauthorizedContents = (Set<Content>) result.get("unauthorized-contents");
250            if (unauthorizedContents.size() > 0)
251            {
252                getLogger().info("The following contents cannot be deleted because they are no authorization: {}", unauthorizedContents.stream().map(m -> m.getId()).collect(Collectors.toList()));
253            }
254            
255            Set<Content> undeletedContents = (Set<Content>) result.get("undeleted-contents");
256            if (undeletedContents.size() > 0)
257            {
258                getLogger().info("{} contents were not deleted. See previous logs for more information.", undeletedContents.size());
259            }
260        }
261        
262        return results;
263    }
264    
265    /**
266     * Delete one content
267     * @param content the content to delete
268     * @param deleteMode The deletion mode
269     * @param ignoreRights If true, bypass the rights check during the deletion
270     * @param results the results map
271     */
272    private void _deleteContent(Content content, DeleteMode deleteMode, boolean ignoreRights, Map<String, Object> results)
273    {
274        boolean success = true;
275        
276        if (content instanceof OrgUnit)
277        {
278            // 1- First delete relation to parent
279            OrgUnit parentOrgUnit = ((OrgUnit) content).getParentOrgUnit();
280            if (parentOrgUnit != null)
281            {
282                success = _removeRelation(parentOrgUnit, content, OrgUnit.CHILD_ORGUNITS, 22, results);
283            }
284            
285            // 2 - If succeed, process to deletion
286            if (success)
287            {
288                _deleteOrgUnit((OrgUnit) content, ignoreRights, results);
289            }
290        }
291        else if (content instanceof ProgramItem)
292        {
293            // 1 - First delete relation to parents
294            if (content instanceof Course)
295            {
296                List<CourseList> courseLists = ((Course) content).getParentCourseLists();
297                success = _removeRelations(courseLists, content, CourseList.CHILD_COURSES, 22, results);
298                
299            }
300            else if (content instanceof ProgramPart)
301            {
302                List<? extends ModifiableWorkflowAwareContent> parentProgramParts = ((ProgramPart) content).getProgramPartParents()
303                                                                                                           .stream()
304                                                                                                           .filter(ModifiableWorkflowAwareContent.class::isInstance)
305                                                                                                           .map(ModifiableWorkflowAwareContent.class::cast)
306                                                                                                           .collect(Collectors.toList());
307                
308                success = _removeRelations(parentProgramParts, content, TraversableProgramPart.CHILD_PROGRAM_PARTS, 22, results);
309    
310                if (success && content instanceof CourseList)
311                {
312                    List<Course> parentCourses = ((CourseList) content).getParentCourses();
313                    success = _removeRelations(parentCourses, content, Course.CHILD_COURSE_LISTS, 22, results);
314                }
315                
316            }
317            else
318            {
319                throw new IllegalArgumentException("The content [" + content.getId() + "] is not of the expected type, it can't be deleted.");
320            }
321            
322            // 2 - If succeed, process to deletion
323            if (success)
324            {
325                _deleteProgramItem((ProgramItem) content, deleteMode, ignoreRights, results);
326                
327                // Notify observers for program items that parent relation has been removed
328                @SuppressWarnings("unchecked")
329                Map<String, List<? extends ProgramItem>> hierarchyChangedContents = (Map<String, List<? extends ProgramItem>>) results.get("hierarchy-changed-contents");
330                for (Entry<String, List< ? extends ProgramItem>> entry : hierarchyChangedContents.entrySet())
331                {
332                    for (ProgramItem programItem : entry.getValue())
333                    {
334                        Map<String, Object> eventParams = new HashMap<>();
335                        eventParams.put(OdfObservationConstants.ARGS_PROGRAM_ITEM, programItem);
336                        eventParams.put(OdfObservationConstants.ARGS_PROGRAM_ITEM_ID, programItem.getId());
337                        eventParams.put(OdfObservationConstants.ARGS_OLD_PARENT_PROGRAM_ITEM_ID, entry.getKey());
338                        
339                        _observationManager.notify(new Event(OdfObservationConstants.EVENT_PROGRAM_ITEM_HIERARCHY_CHANGED, _currentUserProvider.getUser(), eventParams));
340                    }
341                }
342                
343            }
344        }
345        else if (content instanceof Person)
346        {
347            // 1 - Process to deletion
348            _finalizeDeleteContents(Collections.singleton(content.getId()), content.getParent(), results);
349        }
350        else
351        {
352            throw new IllegalArgumentException("The content [" + content.getId() + "] is not of the expected type, it can't be deleted.");
353        }
354
355        if (!success)
356        {
357            @SuppressWarnings("unchecked")
358            Set<Content> undeletedContents = (Set<Content>) results.get("undeleted-contents");
359            undeletedContents.add(content);
360        }
361    }
362
363    /**
364     * Test if content is still referenced before removing it
365     * @param content The content to remove
366     * @return true if content is still referenced
367     */
368    public boolean isContentReferenced(Content content)
369    {
370        return _isContentReferenced(content, null);
371    }
372    
373    /**
374     * Test if content is still referenced before removing it.
375     * @param content The content to remove
376     * @param rootContent the initial content to delete (can be null if checkRoot is false)
377     * @return true if content is still referenced
378     */
379    private boolean _isContentReferenced(Content content, Content rootContent)
380    {
381        if (content instanceof OrgUnit)
382        {
383            return _isReferencedOrgUnit((OrgUnit) content);
384        }
385        else if (content instanceof ProgramItem)
386        {
387            if (rootContent != null)
388            {
389                return _isReferencedContentCheckingRoot((ProgramItem) content, rootContent);
390            }
391            else
392            {
393                List<ProgramItem> ignoredRefContent = _odfHelper.getChildProgramItems((ProgramItem) content);
394                if (!(content instanceof Program)) // TODO remove content instanceof Course
395                {
396                    ignoredRefContent.addAll(_odfHelper.getParentProgramItems((ProgramItem) content));
397                }
398                
399                for (Pair<String, Content> refPair : _contentHelper.getReferencingContents(content))
400                {
401                    Content refContent = refPair.getValue();
402                    String path = refPair.getKey();
403                    
404                    // Ignoring reference from shareable field
405                    if (!(refContent instanceof Course && path.equals(ShareableCourseConstants.PROGRAMS_FIELD_ATTRIBUTE_NAME)))
406                    {
407                        // The ref content is not ignored
408                        if (refContent instanceof ProgramItem programItem && !ignoredRefContent.contains(programItem))
409                        {
410                            return true;
411                        }
412                    }
413                }
414
415                return false;
416            }
417        }
418        else if (content instanceof CoursePart)
419        {
420            if (rootContent != null)
421            {
422                // we don't rely on the CoursePart#getCourses method to avoid issue with dirty data
423                String query = QueryHelper.getXPathQuery(null, CourseFactory.COURSE_NODETYPE, new StringExpression(Course.CHILD_COURSE_PARTS, Operator.EQ, content.getId()));
424                AmetysObjectIterator<ProgramItem> iterator = _resolver.<ProgramItem>query(query).iterator();
425                while (iterator.hasNext())
426                {
427                    if (_isReferencedContentCheckingRoot(iterator.next(), rootContent))
428                    {
429                        return true;
430                    }
431                    
432                }
433                
434                return false;
435            }
436            // There shouldn't be a case were we try to delete a coursePart without deleting it from a Course.
437            // But in case of we support it
438            else
439            {
440                // Verify if the content has no parent courses
441                if (((CoursePart) content).getCourses().isEmpty())
442                {
443                    // Twice...
444                    String query = QueryHelper.getXPathQuery(null, CourseFactory.COURSE_NODETYPE, new StringExpression(Course.CHILD_COURSE_PARTS, Operator.EQ, content.getId()));
445                    return _resolver.query(query).iterator().hasNext();
446                }
447                return true;
448            }
449        }
450        
451        return content.hasReferencingContents();
452    }
453    
454    /**
455     * True if the orgUnit is referenced
456     * @param orgUnit the orgUnit
457     * @return true if the orgUnit is referenced
458     */
459    private boolean _isReferencedOrgUnit(OrgUnit orgUnit)
460    {
461        OrgUnit parentOrgUnit = orgUnit.getParentOrgUnit();
462
463        List<String> relatedOrgUnit = orgUnit.getSubOrgUnits();
464        if (parentOrgUnit != null)
465        {
466            relatedOrgUnit.add(parentOrgUnit.getId());
467        }
468        
469        for (Pair<String, Content> refPair : _contentHelper.getReferencingContents(orgUnit))
470        {
471            Content refContent = refPair.getValue();
472            String path = refPair.getKey();
473            
474            // Ignoring reference from shareable field
475            if (!(refContent instanceof Course && path.equals(ShareableCourseConstants.ORGUNITS_FIELD_ATTRIBUTE_NAME)))
476            {
477                if (!relatedOrgUnit.contains(refContent.getId()))
478                {
479                    return true;
480                }
481            }
482        }
483        
484        return false;
485    }
486
487    /**
488     * Check that deletion can be performed without blocking errors
489     * @param content The initial content to delete
490     * @param mode The deletion mode
491     * @param ignoreRights If true, bypass the rights check during the deletion
492     * @param results The results
493     * @return true if the deletion can be performed
494     */
495    private boolean _checkBeforeDeletion(Content content, DeleteMode mode, boolean ignoreRights, Map<String, Object> results)
496    {
497        // Check right and lock on content it self
498        boolean allRight = _canDeleteContent(content, ignoreRights, results);
499        
500        // Check lock on parent contents
501        allRight = _checkParentsBeforeDeletion(content, results) && allRight;
502        
503        // Check right and lock on children to be deleted or modified
504        allRight = _checkChildrenBeforeDeletion(content, content, mode, ignoreRights, results) && allRight;
505        
506        return allRight;
507    }
508    
509    private boolean _checkParentsBeforeDeletion(Content content,  Map<String, Object> results)
510    {
511        boolean allRight = true;
512        
513        // Check if parents are not locked
514        List< ? extends WorkflowAwareContent> parents = _getParents(content);
515        for (WorkflowAwareContent parent : parents)
516        {
517            if (_smartHelper.isLocked(parent))
518            {
519                @SuppressWarnings("unchecked")
520                Set<Content> lockedContents = (Set<Content>) results.get("locked-contents");
521                lockedContents.add(content);
522                
523                allRight = false;
524            }
525        }
526        
527        return allRight;
528    }
529    
530    /**
531     * Browse children to check if deletion could succeed
532     * @param rootContentToDelete The initial content to delete
533     * @param contentToCheck The current content to check
534     * @param mode The deletion mode
535     * @param ignoreRights If true, bypass the rights check during the deletion
536     * @param results The result
537     * @return true if the deletion can be processed
538     */
539    private boolean _checkChildrenBeforeDeletion(Content rootContentToDelete, Content contentToCheck, DeleteMode mode, boolean ignoreRights, Map<String, Object> results)
540    {
541        boolean allRight = true;
542        
543        if (contentToCheck instanceof ProgramItem)
544        {
545            allRight = _checkChildrenBeforeDeletionOfProgramItem(rootContentToDelete, contentToCheck, mode, ignoreRights, results);
546        }
547        else if (contentToCheck instanceof OrgUnit)
548        {
549            allRight = _checkChildrenBeforeDeletionOfOrgUnit(rootContentToDelete, contentToCheck, mode, ignoreRights, results);
550        }
551        
552        return allRight;
553    }
554    
555    private boolean _checkChildrenBeforeDeletionOfProgramItem(Content rootContentToDelete, Content contentToCheck, DeleteMode mode,  boolean ignoreRights, Map<String, Object> results)
556    {
557        boolean allRight = true;
558
559        List<ProgramItem> childProgramItems = _odfHelper.getChildProgramItems((ProgramItem) contentToCheck);
560        for (ProgramItem childProgramItem : childProgramItems)
561        {
562            Content childContent = (Content) childProgramItem;
563            if (_smartHelper.isLocked(childContent))
564            {
565                // Lock should be checked for all children
566                @SuppressWarnings("unchecked")
567                Set<Content> lockedContents = (Set<Content>) results.get("locked-contents");
568                lockedContents.add(childContent);
569                
570                allRight = false;
571            }
572
573            // If content is not referenced it should be deleted, so check right
574            if ((mode == DeleteMode.FULL
575                    || mode == DeleteMode.STRUCTURE_ONLY && !(childProgramItem instanceof Course))
576                 && !_isContentReferenced(childContent, rootContentToDelete)
577                 && !ignoreRights
578                 && !hasRight(childContent))
579            {
580                // User has no sufficient right
581                @SuppressWarnings("unchecked")
582                Set<Content> norightContents = (Set<Content>) results.get("unauthorized-contents");
583                norightContents.add(childContent);
584                
585                allRight = false;
586            }
587            
588            if (mode != DeleteMode.SINGLE && !(mode == DeleteMode.STRUCTURE_ONLY && childProgramItem instanceof Course))
589            {
590                // Browse children recursively
591                allRight = _checkChildrenBeforeDeletion(rootContentToDelete, childContent, mode, ignoreRights, results) && allRight;
592            }
593        }
594        
595        return allRight;
596    }
597    
598    private boolean _checkChildrenBeforeDeletionOfOrgUnit(Content rootContentToDelete, Content contentToCheck, DeleteMode mode, boolean ignoreRights, Map<String, Object> results)
599    {
600        boolean allRight = true;
601
602        List<String> childOrgUnits = ((OrgUnit) contentToCheck).getSubOrgUnits();
603        for (String childOrgUnitId : childOrgUnits)
604        {
605            OrgUnit childOrgUnit = _resolver.resolveById(childOrgUnitId);
606            if (!_canDeleteContent(childOrgUnit, ignoreRights, results))
607            {
608                allRight = false;
609            }
610            else if (_isReferencedOrgUnit(childOrgUnit))
611            {
612                @SuppressWarnings("unchecked")
613                Set<Content> referencedContents = (Set<Content>) results.get("referenced-contents");
614                referencedContents.add(childOrgUnit);
615                
616                allRight = false;
617            }
618            else
619            {
620                // Browse children recursively
621                allRight = _checkChildrenBeforeDeletion(rootContentToDelete, childOrgUnit, mode, ignoreRights, results) && allRight;
622            }
623        }
624        
625        return allRight;
626    }
627
628    /**
629     * Remove the relations between the content and its contents list
630     * @param contentsToEdit the contents to edit
631     * @param refContentToRemove The referenced content to be removed from contents
632     * @param attributeName The name of attribute holding the relationship
633     * @param actionId The id of workflow action to edit the relation
634     * @param results the results map
635     * @return true if remove relation successfully
636     */
637    private boolean _removeRelations(List<? extends ModifiableWorkflowAwareContent> contentsToEdit, Content refContentToRemove, String attributeName, int actionId, Map<String, Object> results)
638    {
639        boolean success = true;
640        
641        for (ModifiableWorkflowAwareContent contentToEdit : contentsToEdit)
642        {
643            success = _removeRelation(contentToEdit, refContentToRemove, attributeName, actionId, results) && success;
644        }
645        
646        return success;
647    }
648    
649    /**
650     * Remove the relation parent-child relation on content.
651     * @param contentToEdit The content to modified
652     * @param refContentToRemove The referenced content to be removed from content
653     * @param attributeName The name of attribute holding the child or parent relationship
654     * @param actionId The id of workflow action to edit the relation
655     * @param results the results map
656     * @return boolean true if remove relation successfully
657     */
658    private boolean _removeRelation(ModifiableWorkflowAwareContent contentToEdit, Content refContentToRemove, String attributeName, int actionId, Map<String, Object> results)
659    {
660        try
661        {
662            List<String> values = ContentDataHelper.getContentIdsListFromMultipleContentData(contentToEdit, attributeName);
663            
664            if (values.contains(refContentToRemove.getId()))
665            {
666                values.remove(refContentToRemove.getId());
667                contentToEdit.setValue(attributeName, values.toArray(new String[values.size()]));
668                contentToEdit.removeExternalizableMetadataIfExists(attributeName);
669                
670                _applyChanges(contentToEdit, actionId);
671            }
672            
673            return true;
674        }
675        catch (WorkflowException | AmetysRepositoryException e)
676        {
677            getLogger().error("Unable to remove relationship to content {} ({}) on content {} ({}) for metadata {}", refContentToRemove.getTitle(), refContentToRemove.getId(), contentToEdit.getTitle(), contentToEdit.getId(), attributeName, e);
678            return false;
679        }
680    }
681    
682    /**
683     * Delete a program item
684     * @param item The program item to delete
685     * @param mode The deletion mode
686     * @param ignoreRights If true, bypass the rights check during the deletion
687     * @param results The results
688     */
689    private void _deleteProgramItem(ProgramItem item, DeleteMode mode, boolean ignoreRights, Map<String, Object> results)
690    {
691        if (mode == DeleteMode.SINGLE)
692        {
693            if (_canDeleteContent((Content) item, ignoreRights, results))
694            {
695                Set<String> toDelete = new HashSet<>();
696                String idToDelete = item.getId();
697                toDelete.add(idToDelete);
698                // 1 - First remove relations with children
699                String parentMetadataName;
700                if (item instanceof Course)
701                {
702                    parentMetadataName = CourseList.PARENT_COURSES;
703                    toDelete.addAll(_getCoursePartsToDelete((Course) item, item, results));
704                }
705                else if (item instanceof CourseList)
706                {
707                    parentMetadataName = Course.PARENT_COURSE_LISTS;
708                }
709                else
710                {
711                    parentMetadataName = ProgramPart.PARENT_PROGRAM_PARTS;
712                }
713                
714                List<ProgramItem> childProgramItems = _odfHelper.getChildProgramItems(item);
715                List<ModifiableWorkflowAwareContent> childContents = childProgramItems.stream()
716                        .filter(ModifiableWorkflowAwareContent.class::isInstance)
717                        .map(ModifiableWorkflowAwareContent.class::cast)
718                        .collect(Collectors.toList());
719                
720                boolean success = _removeRelations(childContents, (Content) item, parentMetadataName, 22, results);
721                if (success)
722                {
723                    // If success delete course
724                    _finalizeDeleteContents(toDelete, item.getParent(), results);
725                    
726                    @SuppressWarnings("unchecked")
727                    Set<String> deletedContents = (Set<String>) results.get("deleted-contents");
728                    if (deletedContents.contains(idToDelete))
729                    {
730                        @SuppressWarnings("unchecked")
731                        Map<String, List<? extends ProgramItem>> hierarchyChangedContents = (Map<String, List<? extends ProgramItem>>) results.get("hierarchy-changed-contents");
732                        hierarchyChangedContents.put(idToDelete, childProgramItems);
733                    }
734                }
735            }
736        }
737        else
738        {
739            Set<String> toDelete = _getChildrenIdToDelete(item, item, results, mode, ignoreRights);
740            _finalizeDeleteContents(toDelete, item.getParent(), results);
741        }
742    }
743    
744   
745    private Set<String> _getCoursePartsToDelete(Course course, ProgramItem initialContentToDelete, Map<String, Object> results)
746    {
747        Set<String> toDelete = new HashSet<>();
748        for (CoursePart childCoursePart : course.getCourseParts())
749        {
750            // check if the coursePart is referenced
751            if (!_isContentReferenced(childCoursePart, (Content) initialContentToDelete))
752            {
753                // we don't check if we can delete the coursePart as we have already check it's course
754                // we can add it to the list of content that will be deleted
755                toDelete.add(childCoursePart.getId());
756            }
757            // the content is still referenced, so we remove the relation from the course part
758            else
759            {
760                _removeRelation(childCoursePart, course, CoursePart.PARENT_COURSES, 22, results);
761            }
762        }
763        return toDelete;
764    }
765
766    /**
767     * Delete one orgUnit
768     * @param orgUnit the orgUnit to delete
769     * @param ignoreRights If true, bypass the rights check during the deletion
770     * @param results the results map
771     */
772    @SuppressWarnings("unchecked")
773    private void _deleteOrgUnit(OrgUnit orgUnit, boolean ignoreRights, Map<String, Object> results)
774    {
775        Set<String> toDelete = _getChildrenIdToDelete(orgUnit, ignoreRights, results);
776        
777        Set<Content> referencedContents = (Set<Content>) results.get("referenced-contents");
778        Set<Content> lockedContents = (Set<Content>) results.get("locked-contents");
779        Set<Content> unauthorizedContents = (Set<Content>) results.get("unauthorized-contents");
780        
781        if (referencedContents.size() == 0 && lockedContents.size() == 0 && unauthorizedContents.size() == 0)
782        {
783            _finalizeDeleteContents(toDelete, orgUnit.getParent(), results);
784        }
785    }
786
787    /**
788     * Finalize the deletion of contents. Call observers and remove contents
789     * @param contentIdsToDelete the list of content id to delete
790     * @param parent the jcr parent for saving changes
791     * @param results the results map
792     */
793    private void _finalizeDeleteContents(Set<String> contentIdsToDelete, ModifiableAmetysObject parent, Map<String, Object> results)
794    {
795        @SuppressWarnings("unchecked")
796        Set<Content> unauthorizedContents = (Set<Content>) results.get("unauthorized-contents");
797        @SuppressWarnings("unchecked")
798        Set<Content> lockedContents = (Set<Content>) results.get("locked-contents");
799        
800        if (!unauthorizedContents.isEmpty() || !lockedContents.isEmpty())
801        {
802            //Do Nothing
803            return;
804        }
805        
806        try
807        {
808            _solrIndexHelper.pauseSolrCommitForEvents(new String[] {ObservationConstants.EVENT_CONTENT_DELETED});
809            
810            Map<String, Map<String, Object>> eventParams = new HashMap<>();
811            for (String id : contentIdsToDelete)
812            {
813                Content content = _resolver.resolveById(id);
814                Map<String, Object> eventParam = _getEventParametersForDeletion(content);
815                
816                eventParams.put(id, eventParam);
817                
818                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParam));
819                
820                // Remove the content.
821                LockableAmetysObject lockedContent = (LockableAmetysObject) content;
822                if (lockedContent.isLocked())
823                {
824                    lockedContent.unlock();
825                }
826                
827                ((RemovableAmetysObject) content).remove();
828            }
829            
830            
831            parent.saveChanges();
832            
833            for (String id : contentIdsToDelete)
834            {
835                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams.get(id)));
836                
837                @SuppressWarnings("unchecked")
838                Set<String> deletedContents = (Set<String>) results.get("deleted-contents");
839                deletedContents.add(id);
840            }
841        }
842        finally
843        {
844            _solrIndexHelper.restartSolrCommitForEvents(new String[] {ObservationConstants.EVENT_CONTENT_DELETED});
845        }
846    }
847    
848    /**
849     * True if we can delete the content (check if removable, rights and if locked)
850     * @param content the content
851     * @param ignoreRights If true, bypass the rights check during the deletion
852     * @param results the results map
853     * @return true if we can delete the content
854     */
855    private boolean _canDeleteContent(Content content, boolean ignoreRights, Map<String, Object> results)
856    {
857        if (!(content instanceof RemovableAmetysObject))
858        {
859            throw new IllegalArgumentException("The content [" + content.getId() + "] is not a RemovableAmetysObject, it can't be deleted.");
860        }
861        
862        if (!ignoreRights && !hasRight(content))
863        {
864            // User has no sufficient right
865            @SuppressWarnings("unchecked")
866            Set<Content> norightContents = (Set<Content>) results.get("unauthorized-contents");
867            norightContents.add(content);
868            
869            return false;
870        }
871        else if (_smartHelper.isLocked(content))
872        {
873            @SuppressWarnings("unchecked")
874            Set<Content> lockedContents = (Set<Content>) results.get("locked-contents");
875            lockedContents.add(content);
876            
877            return false;
878        }
879        
880        return true;
881    }
882    
883    private void _applyChanges(WorkflowAwareContent content, int actionId) throws WorkflowException
884    {
885        // Notify listeners
886        Map<String, Object> eventParams = new HashMap<>();
887        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
888        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
889        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _currentUserProvider.getUser(), eventParams));
890       
891        _contentWorkflowHelper.doAction(content, actionId);
892    }
893    
894    /**
895     * Get parameters for content deleted {@link Event}
896     * @param content the removed content
897     * @return the event's parameters
898     */
899    private Map<String, Object> _getEventParametersForDeletion (Content content)
900    {
901        Map<String, Object> eventParams = new HashMap<>();
902        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
903        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
904        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
905        return eventParams;
906    }
907    
908    private List<? extends WorkflowAwareContent> _getParents(Content content)
909    {
910        if (content instanceof OrgUnit)
911        {
912            return Collections.singletonList(((OrgUnit) content).getParentOrgUnit());
913        }
914        else if (content instanceof Course)
915        {
916            return ((Course) content).getParentCourseLists();
917        }
918        else if (content instanceof CourseList)
919        {
920            List<ProgramPart> parentProgramItems = ((CourseList) content).getProgramPartParents();
921            List<WorkflowAwareContent> parents = parentProgramItems.stream().map(p -> (WorkflowAwareContent) p).collect(Collectors.toList());
922            
923            List<Course> parentCourses = ((CourseList) content).getParentCourses();
924            parents.addAll(parentCourses.stream().map(p -> (WorkflowAwareContent) p).collect(Collectors.toList()));
925            
926            return parents;
927        }
928        else if (content instanceof ProgramPart)
929        {
930            List<ProgramItem> parentProgramItems = _odfHelper.getParentProgramItems((ProgramPart) content);
931            return parentProgramItems.stream().map(p -> (WorkflowAwareContent) p).collect(Collectors.toList());
932        }
933        
934        return Collections.EMPTY_LIST;
935    }
936    
937    /**
938     * Get the id of children to be deleted.
939     * All children shared with other contents which are not part of deletion, will be not deleted.
940     * @param orgUnit The orgunit to delete
941     * @param ignoreRights If true, bypass the rights check during the deletion
942     * @param results The results
943     * @return The id of contents to be deleted
944     */
945    private Set<String> _getChildrenIdToDelete (OrgUnit orgUnit, boolean ignoreRights, Map<String, Object> results)
946    {
947        Set<String> toDelete = new HashSet<>();
948        
949        if (_canDeleteContent(orgUnit, ignoreRights, results))
950        {
951            toDelete.add(orgUnit.getId());
952            
953            for (String childId : orgUnit.getSubOrgUnits())
954            {
955                OrgUnit childOrgUnit = _resolver.resolveById(childId);
956                
957                if (!_isReferencedOrgUnit(orgUnit))
958                {
959                    toDelete.addAll(_getChildrenIdToDelete(childOrgUnit, ignoreRights, results));
960                }
961                else
962                {
963                    // The child program item can not be deleted, remove the relation to the parent and stop iteration
964                    @SuppressWarnings("unchecked")
965                    Set<Content> referencedContents = (Set<Content>) results.get("referenced-contents");
966                    referencedContents.add(childOrgUnit);
967                }
968            }
969        }
970        
971        return toDelete;
972    }
973    
974    /**
975     * Get the id of children to be deleted.
976     * All children shared with other contents which are not part of deletion, will be not deleted.
977     * @param contentToDelete The content to delete in the tree of initial content to delete
978     * @param initialContentToDelete The initial content to delete
979     * @param results The results
980     * @param mode The deletion mode
981     * @param ignoreRights If true, bypass the rights check during the deletion
982     * @return The id of contents to be deleted
983     */
984    private Set<String> _getChildrenIdToDelete (ProgramItem contentToDelete, ProgramItem initialContentToDelete, Map<String, Object> results, DeleteMode mode, boolean ignoreRights)
985    {
986        Set<String> toDelete = new HashSet<>();
987        
988        if (_canDeleteContent((Content) contentToDelete, ignoreRights, results))
989        {
990            toDelete.add(contentToDelete.getId());
991            
992            // First we start by adding the coursePart if it's a Course
993            if (contentToDelete instanceof Course)
994            {
995                toDelete.addAll(_getCoursePartsToDelete((Course) contentToDelete, initialContentToDelete, results));
996            }
997            
998            List<ProgramItem> childProgramItems;
999            if (mode == DeleteMode.STRUCTURE_ONLY && contentToDelete instanceof TraversableProgramPart)
1000            {
1001                // Get subprogram, container and course list children only
1002                childProgramItems = ((TraversableProgramPart) contentToDelete).getProgramPartChildren().stream().map(ProgramItem.class::cast).collect(Collectors.toList());
1003            }
1004            else
1005            {
1006                childProgramItems = _odfHelper.getChildProgramItems(contentToDelete);
1007            }
1008            
1009            for (ProgramItem childProgramItem : childProgramItems)
1010            {
1011                if (!_isContentReferenced((Content) childProgramItem, (Content) initialContentToDelete))
1012                {
1013                    // If all references of this program item is part of the initial content to delete, it can be deleted
1014                    if (mode == DeleteMode.STRUCTURE_ONLY && childProgramItem instanceof CourseList)
1015                    {
1016                        // Remove the relations to the course list to be deleted on all child courses
1017                        _removeRelations(((CourseList) childProgramItem).getCourses(), (Content) childProgramItem, Course.PARENT_COURSE_LISTS, 22, results);
1018                        toDelete.add(childProgramItem.getId());
1019                        
1020                        @SuppressWarnings("unchecked")
1021                        Map<String, List<? extends ProgramItem>> hierarchyChangedContents = (Map<String, List<? extends ProgramItem>>) results.get("hierarchy-changed-contents");
1022                        hierarchyChangedContents.put(childProgramItem.getId(), ((CourseList) childProgramItem).getCourses());
1023                    }
1024                    else
1025                    {
1026                        // Browse children recursively
1027                        toDelete.addAll(_getChildrenIdToDelete(childProgramItem, initialContentToDelete, results, mode, ignoreRights));
1028                    }
1029                }
1030                else
1031                {
1032                    // The child program item can not be deleted, remove the relation to the parent and stop iteration
1033                    String parentMetadataName;
1034                    if (childProgramItem instanceof CourseList)
1035                    {
1036                        parentMetadataName = contentToDelete instanceof Course ? CourseList.PARENT_COURSES : ProgramPart.PARENT_PROGRAM_PARTS;
1037                    }
1038                    else if (childProgramItem instanceof Course)
1039                    {
1040                        parentMetadataName = Course.PARENT_COURSE_LISTS;
1041                    }
1042                    else
1043                    {
1044                        parentMetadataName = ProgramPart.PARENT_PROGRAM_PARTS;
1045                    }
1046                    
1047                    _removeRelation((ModifiableWorkflowAwareContent) childProgramItem, (Content) contentToDelete, parentMetadataName, 22, results);
1048                    
1049                    @SuppressWarnings("unchecked")
1050                    Set<Content> referencedContents = (Set<Content>) results.get("referenced-contents");
1051                    referencedContents.add((Content) childProgramItem);
1052                    
1053                    @SuppressWarnings("unchecked")
1054                    Map<String, List<? extends ProgramItem>> hierarchyChangedContents = (Map<String, List<? extends ProgramItem>>) results.get("hierarchy-changed-contents");
1055                    hierarchyChangedContents.put(contentToDelete.getId(), List.of(childProgramItem));
1056                }
1057            }
1058        }
1059        
1060        return toDelete;
1061    }
1062
1063    /**
1064     * Determines if the user has sufficient right for the given content
1065     * @param content the content
1066     * @return true if user has sufficient right
1067     */
1068    public boolean hasRight(Content content)
1069    {
1070        String rightId = _getRightId(content);
1071        return _rightManager.hasRight(_currentUserProvider.getUser(), rightId, content) == RightResult.RIGHT_ALLOW;
1072    }
1073    
1074    private String _getRightId (Content content)
1075    {
1076        if (content instanceof Course)
1077        {
1078            return "ODF_Rights_Course_Delete";
1079        }
1080        else if (content instanceof SubProgram)
1081        {
1082            return "ODF_Rights_SubProgram_Delete";
1083        }
1084        else if (content instanceof Container)
1085        {
1086            return "ODF_Rights_Container_Delete";
1087        }
1088        else if (content instanceof Program)
1089        {
1090            return "ODF_Rights_Program_Delete";
1091        }
1092        else if (content instanceof Person)
1093        {
1094            return "ODF_Rights_Person_Delete";
1095        }
1096        else if (content instanceof OrgUnit)
1097        {
1098            return "ODF_Rights_OrgUnit_Delete";
1099        }
1100        else if (content instanceof CourseList)
1101        {
1102            return "ODF_Rights_CourseList_Delete";
1103        }
1104        return "CMS_Rights_DeleteContent";
1105    }
1106    
1107    /**
1108     * True if the content is referenced (we are ignoring parent references if they have same root)
1109     * @param programItem the program item
1110     * @param initialContentToDelete the initial content to delete
1111     * @return true if the content is referenced
1112     */
1113    private boolean _isReferencedContentCheckingRoot(ProgramItem programItem, Content initialContentToDelete)
1114    {
1115        if (programItem.getId().equals(initialContentToDelete.getId()))
1116        {
1117            return false;
1118        }
1119
1120        List<ProgramItem> parentProgramItems = _odfHelper.getParentProgramItems(programItem);
1121        if (parentProgramItems.isEmpty())
1122        {
1123            // We have found the root parent of our item. but it's not the initial content to delete
1124            return true;
1125        }
1126        
1127        for (ProgramItem parentProgramItem : parentProgramItems)
1128        {
1129            if (_isReferencedContentCheckingRoot(parentProgramItem, initialContentToDelete))
1130            {
1131                return true;
1132            }
1133        }
1134        
1135        return false;
1136    }
1137}