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