001/*
002 *  Copyright 2014 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.catalog;
017
018import java.time.Duration;
019import java.time.ZonedDateTime;
020import java.time.temporal.ChronoUnit;
021import java.util.ArrayList;
022import java.util.Collection;
023import java.util.HashMap;
024import java.util.List;
025import java.util.Map;
026import java.util.Optional;
027import java.util.Set;
028import java.util.stream.Collectors;
029import java.util.stream.Stream;
030
031import org.apache.avalon.framework.component.Component;
032import org.apache.avalon.framework.context.Context;
033import org.apache.avalon.framework.context.ContextException;
034import org.apache.avalon.framework.context.Contextualizable;
035import org.apache.avalon.framework.service.ServiceException;
036import org.apache.avalon.framework.service.ServiceManager;
037import org.apache.avalon.framework.service.Serviceable;
038import org.apache.cocoon.ProcessingException;
039import org.apache.cocoon.components.ContextHelper;
040import org.apache.cocoon.environment.Request;
041import org.apache.commons.lang.StringUtils;
042
043import org.ametys.cms.ObservationConstants;
044import org.ametys.cms.content.archive.ArchiveConstants;
045import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
046import org.ametys.cms.data.ContentValue;
047import org.ametys.cms.indexing.solr.SolrIndexHelper;
048import org.ametys.cms.repository.Content;
049import org.ametys.cms.repository.ContentQueryHelper;
050import org.ametys.cms.repository.ContentTypeExpression;
051import org.ametys.cms.repository.LanguageExpression;
052import org.ametys.cms.repository.ModifiableDefaultContent;
053import org.ametys.cms.repository.WorkflowAwareContent;
054import org.ametys.cms.rights.ContentRightAssignmentContext;
055import org.ametys.cms.workflow.ContentWorkflowHelper;
056import org.ametys.core.observation.Event;
057import org.ametys.core.observation.ObservationManager;
058import org.ametys.core.schedule.progression.ContainerProgressionTracker;
059import org.ametys.core.schedule.progression.SimpleProgressionTracker;
060import org.ametys.core.ui.Callable;
061import org.ametys.core.user.CurrentUserProvider;
062import org.ametys.odf.ODFHelper;
063import org.ametys.odf.ProgramItem;
064import org.ametys.odf.course.Course;
065import org.ametys.odf.course.CourseContainer;
066import org.ametys.odf.courselist.CourseList;
067import org.ametys.odf.courselist.CourseListContainer;
068import org.ametys.odf.coursepart.CoursePart;
069import org.ametys.odf.coursepart.CoursePartFactory;
070import org.ametys.odf.data.EducationalPath;
071import org.ametys.odf.program.Program;
072import org.ametys.odf.program.ProgramFactory;
073import org.ametys.odf.program.TraversableProgramPart;
074import org.ametys.plugins.repository.AmetysObject;
075import org.ametys.plugins.repository.AmetysObjectIterable;
076import org.ametys.plugins.repository.AmetysObjectResolver;
077import org.ametys.plugins.repository.AmetysRepositoryException;
078import org.ametys.plugins.repository.ModifiableAmetysObject;
079import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
080import org.ametys.plugins.repository.RemovableAmetysObject;
081import org.ametys.plugins.repository.RepositoryConstants;
082import org.ametys.plugins.repository.TraversableAmetysObject;
083import org.ametys.plugins.repository.UnknownAmetysObjectException;
084import org.ametys.plugins.repository.lock.LockableAmetysObject;
085import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
086import org.ametys.plugins.repository.query.QueryHelper;
087import org.ametys.plugins.repository.query.SortCriteria;
088import org.ametys.plugins.repository.query.expression.AndExpression;
089import org.ametys.plugins.repository.query.expression.Expression;
090import org.ametys.plugins.repository.query.expression.Expression.Operator;
091import org.ametys.plugins.repository.query.expression.StringExpression;
092import org.ametys.runtime.plugin.component.AbstractLogEnabled;
093import org.ametys.runtime.plugin.component.PluginAware;
094
095import com.opensymphony.workflow.WorkflowException;
096
097/**
098 * Component to handle ODF catalogs
099 */
100public class CatalogsManager extends AbstractLogEnabled implements Serviceable, Component, PluginAware, Contextualizable
101{
102    /** Avalon Role */
103    public static final String ROLE = CatalogsManager.class.getName();
104
105    private AmetysObjectResolver _resolver;
106
107    private CopyCatalogUpdaterExtensionPoint _copyUpdaterEP;
108
109    private ObservationManager _observationManager;
110
111    private CurrentUserProvider _userProvider;
112
113    private ContentWorkflowHelper _contentWorkflowHelper;
114
115    private String _pluginName;
116
117    private ODFHelper _odfHelper;
118
119    private ContentTypeExtensionPoint _cTypeEP;
120    
121    private Context _context;
122
123    private SolrIndexHelper _solrIndexHelper;
124    
125    private String _defaultCatalogId;
126
127    private CurrentUserProvider _currentUserProvider;
128
129    public void contextualize(Context context) throws ContextException
130    {
131        _context = context;
132    }
133    
134    @Override
135    public void service(ServiceManager manager) throws ServiceException
136    {
137        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
138        _copyUpdaterEP = (CopyCatalogUpdaterExtensionPoint) manager.lookup(CopyCatalogUpdaterExtensionPoint.ROLE);
139        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
140        _userProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
141        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
142        _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE);
143        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
144        _solrIndexHelper = (SolrIndexHelper) manager.lookup(SolrIndexHelper.ROLE);
145        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
146    }
147    
148    public void setPluginInfo(String pluginName, String featureName, String id)
149    {
150        _pluginName = pluginName;
151    }
152    
153    /**
154     * Get the list of catalogs
155     * @return the catalogs
156     */
157    public List<Catalog> getCatalogs()
158    {
159        List<Catalog> result = new ArrayList<>();
160
161        TraversableAmetysObject catalogsNode = getCatalogsRootNode();
162        
163        AmetysObjectIterable<Catalog> catalogs = catalogsNode.getChildren();
164        for (Catalog catalog : catalogs)
165        {
166            result.add(catalog);
167        }
168        
169        return result;
170    }
171
172    /**
173     * Get a catalog matching with the given name
174     * @param name The name
175     * @return a catalog, or null if not found
176     */
177    public Catalog getCatalog(String name)
178    {
179        ModifiableTraversableAmetysObject catalogsNode = getCatalogsRootNode();
180        
181        if (StringUtils.isNotEmpty(name) && catalogsNode.hasChild(name))
182        {
183            return catalogsNode.getChild(name);
184        }
185        
186        // Not found
187        return null;
188    }
189    
190    /**
191     * Returns the name of the default catalog
192     * @return the name of the default catalog
193     */
194    @Callable (rights = Callable.NO_CHECK_REQUIRED)
195    public String getDefaultCatalogName()
196    {
197        return Optional.ofNullable(getDefaultCatalog())
198                       .map(Catalog::getName)
199                       .orElse(null);
200    }
201    
202    /**
203     * Returns the default catalog
204     * @return the default catalog or null if no default catalog was defined.
205     */
206    public synchronized Catalog getDefaultCatalog()
207    {
208        if (_defaultCatalogId == null)
209        {
210            updateDefaultCatalog();
211        }
212        
213        if (_defaultCatalogId != null)
214        {
215            try
216            {
217                return _resolver.resolveById(_defaultCatalogId);
218            }
219            catch (UnknownAmetysObjectException e)
220            {
221                _defaultCatalogId = null;
222            }
223        }
224        
225        return null;
226    }
227    
228    /**
229     * Updates the default catalog (if it's null or if the user has updated it).
230     */
231    void updateDefaultCatalog()
232    {
233        List<Catalog> catalogs = getCatalogs();
234        for (Catalog catalog : catalogs)
235        {
236            if (catalog.isDefault())
237            {
238                _defaultCatalogId = catalog.getId();
239                return;
240            }
241        }
242        
243        // If no default catalog found, get the only catalog if it exists
244        if (catalogs.size() == 1)
245        {
246            _defaultCatalogId = catalogs.get(0).getId();
247        }
248    }
249    
250    /**
251     * Get the name of the catalog of a ODF content
252     * @param contentId The id of content
253     * @return The catalog's name
254     */
255    @Callable (rights = Callable.READ_ACCESS, paramIndex = 0, rightContext = ContentRightAssignmentContext.ID)
256    public String getContentCatalog(String contentId)
257    {
258        Content content = _resolver.resolveById(contentId);
259        
260        if (content instanceof ProgramItem)
261        {
262            return ((ProgramItem) content).getCatalog();
263        }
264        
265        // catalog can also be present on skills
266        // FIXME ODF-3966 - With a new mixin for catalog aware contents, we could have same kind of API that the one existing on ProgramItem
267        if (content.hasValue("catalog"))
268        {
269            return content.getValue("catalog");
270        }
271        
272        // Get catalog from its parents (unecessary ?)
273        AmetysObject parent = content.getParent();
274        while (parent != null)
275        {
276            if (parent instanceof ProgramItem)
277            {
278                return ((ProgramItem) parent).getCatalog();
279            }
280            parent = parent.getParent();
281        }
282        
283        return null;
284    }
285    
286    /**
287     * Determines if the catalog can be modified from the given content
288     * @param contentId The content id
289     * @return A map with success=false if the catalog cannot be edited
290     */
291    @Callable (rights = "ODF_Rights_EditCatalog", paramIndex = 0, rightContext = ContentRightAssignmentContext.ID)
292    public Map<String, Object> canEditCatalog(String contentId)
293    {
294        Map<String, Object> result = new HashMap<>();
295        
296        Content content = _resolver.resolveById(contentId);
297        
298        if (content instanceof ProgramItem)
299        {
300            if (_isReferenced(content))
301            {
302                result.put("success", false);
303                result.put("error", "referenced");
304            }
305            else if (_hasSharedContent((ProgramItem) content, (ProgramItem) content))
306            {
307                result.put("success", false);
308                result.put("error", "hasSharedContent");
309            }
310            else
311            {
312                result.put("success", true);
313            }
314            
315        }
316        else
317        {
318            result.put("success", false);
319            result.put("error", "typeError");
320        }
321        
322        return result;
323    }
324    
325    private boolean _isReferenced (Content content)
326    {
327        return !_odfHelper.getParentProgramItems((ProgramItem) content).isEmpty();
328    }
329    
330    private boolean _hasSharedContent (ProgramItem rootProgramItem, ProgramItem programItem)
331    {
332        List<ProgramItem> children = _odfHelper.getChildProgramItems(programItem);
333        
334        for (ProgramItem child : children)
335        {
336            if (_isShared(rootProgramItem, child))
337            {
338                return true;
339            }
340        }
341        
342        if (programItem instanceof Course)
343        {
344            List<CoursePart> courseParts = ((Course) programItem).getCourseParts();
345            for (CoursePart coursePart : courseParts)
346            {
347                List<ProgramItem> parentCourses = coursePart.getCourses()
348                        .stream()
349                        .map(ProgramItem.class::cast)
350                        .collect(Collectors.toList());
351                if (parentCourses.size() > 1 && !_isPartOfSameStructure(rootProgramItem, parentCourses))
352                {
353                    return true;
354                }
355            }
356        }
357        
358        return false;
359    }
360    
361    private boolean _isShared(ProgramItem rootProgramItem, ProgramItem programItem)
362    {
363        try
364        {
365            List<ProgramItem> parents = _odfHelper.getParentProgramItems(programItem);
366            if (parents.size() > 1 && !_isPartOfSameStructure(rootProgramItem, parents)
367                || _hasSharedContent(rootProgramItem, programItem))
368            {
369                return true;
370            }
371        }
372        catch (UnknownAmetysObjectException e)
373        {
374            // Nothing
375        }
376        
377        return false;
378    }
379    
380    private boolean _isPartOfSameStructure(ProgramItem rootProgramItem, List<ProgramItem> programItems)
381    {
382        for (ProgramItem programItem : programItems)
383        {
384            boolean isPartOfInitalStructure = false;
385            
386            List<EducationalPath> ancestorPaths = _odfHelper.getEducationalPaths(programItem);
387            
388            for (EducationalPath ancestorPath : ancestorPaths)
389            {
390                isPartOfInitalStructure = ancestorPath.resolveProgramItems(_resolver).anyMatch(p -> p.equals(rootProgramItem));
391                break;
392            }
393            
394            if (!isPartOfInitalStructure)
395            {
396                // The content is shared outside the program item to edit
397                return false;
398            }
399        }
400        
401        return true;
402    }
403    
404    /**
405     * Set the catalog of a content. This will modify recursively the catalog of referenced children
406     * @param catalog The catalog
407     * @param contentId The id of content to edit
408     * @throws WorkflowException if an error occurred
409     */
410    @Callable (rights = "ODF_Rights_EditCatalog", paramIndex = 1, rightContext = ContentRightAssignmentContext.ID)
411    public void setContentCatalog(String catalog, String contentId) throws WorkflowException
412    {
413        Content content = _resolver.resolveById(contentId);
414        
415        if (content instanceof ProgramItem)
416        {
417            _setCatalog(content, catalog);
418        }
419        else
420        {
421            throw new IllegalArgumentException("You can not edit the catalog of the content " + contentId);
422        }
423    }
424    
425    private void _setCatalog (Content content, String catalogName) throws WorkflowException
426    {
427        if (content instanceof ProgramItem)
428        {
429            String oldCatalog = ((ProgramItem) content).getCatalog();
430            if (!catalogName.equals(oldCatalog))
431            {
432                ((ProgramItem) content).setCatalog(catalogName);
433                
434                if (content instanceof WorkflowAwareContent)
435                {
436                    _applyChanges((WorkflowAwareContent) content);
437                }
438            }
439        }
440        else if (content instanceof CoursePart)
441        {
442            String oldCatalog = ((CoursePart) content).getCatalog();
443            if (!catalogName.equals(oldCatalog))
444            {
445                ((CoursePart) content).setCatalog(catalogName);
446                
447                if (content instanceof WorkflowAwareContent)
448                {
449                    _applyChanges((WorkflowAwareContent) content);
450                }
451            }
452        }
453        
454        _setCatalogToChildren(content, catalogName);
455    }
456    
457    private void _setCatalogToChildren (Content content, String catalogName) throws WorkflowException
458    {
459        if (content instanceof TraversableProgramPart)
460        {
461            ContentValue[] children = content.getValue(TraversableProgramPart.CHILD_PROGRAM_PARTS, false, new ContentValue[0]);
462            for (ContentValue child : children)
463            {
464                try
465                {
466                    _setCatalog(child.getContent(), catalogName);
467                }
468                catch (UnknownAmetysObjectException e)
469                {
470                    // Nothing
471                }
472            }
473        }
474        
475        if (content instanceof CourseContainer)
476        {
477            for (Course course : ((CourseContainer) content).getCourses())
478            {
479                _setCatalog(course, catalogName);
480            }
481        }
482        
483        if (content instanceof CourseListContainer)
484        {
485            for (CourseList cl : ((CourseListContainer) content).getCourseLists())
486            {
487                _setCatalog(cl, catalogName);
488            }
489        }
490        
491        if (content instanceof Course)
492        {
493            for (CoursePart coursePart : ((Course) content).getCourseParts())
494            {
495                _setCatalog(coursePart, catalogName);
496            }
497        }
498    }
499    
500    private void _applyChanges(WorkflowAwareContent content) throws WorkflowException
501    {
502        ((ModifiableDefaultContent) content).setLastContributor(_userProvider.getUser());
503        ((ModifiableDefaultContent) content).setLastModified(ZonedDateTime.now());
504        
505        // Remove the proposal date.
506        content.setProposalDate(null);
507        
508        // Save changes
509        content.saveChanges();
510        
511        // Notify listeners
512        Map<String, Object> eventParams = new HashMap<>();
513        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
514        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
515        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _userProvider.getUser(), eventParams));
516       
517        _contentWorkflowHelper.doAction(content, 22);
518    }
519    
520    /**
521     * Get the root catalogs storage object.
522     * @return the root catalogs node
523     * @throws AmetysRepositoryException if a repository error occurs.
524     */
525    public ModifiableTraversableAmetysObject getCatalogsRootNode() throws AmetysRepositoryException
526    {
527        String originalWorkspace = null;
528        Request request = ContextHelper.getRequest(_context);
529        try
530        {
531            originalWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
532            if (ArchiveConstants.ARCHIVE_WORKSPACE.equals(originalWorkspace))
533            {
534                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, RepositoryConstants.DEFAULT_WORKSPACE);
535            }
536            
537            ModifiableTraversableAmetysObject rootNode = _resolver.resolveByPath("/");
538            ModifiableTraversableAmetysObject pluginsNode = _getOrCreateNode(rootNode, "ametys:plugins", "ametys:unstructured");
539            ModifiableTraversableAmetysObject pluginNode = _getOrCreateNode(pluginsNode, _pluginName, "ametys:unstructured");
540            
541            return _getOrCreateNode(pluginNode, "catalogs", "ametys:unstructured");
542        }
543        catch (AmetysRepositoryException e)
544        {
545            throw new AmetysRepositoryException("Unable to get the ODF catalogs root node", e);
546        }
547        finally
548        {
549            if (ArchiveConstants.ARCHIVE_WORKSPACE.equals(originalWorkspace))
550            {
551                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, originalWorkspace);
552            }
553        }
554    }
555    
556    /**
557     * Create a new catalog
558     * @param name The unique name
559     * @param title The title of catalog
560     * @return the created catalog
561     */
562    public Catalog createCatalog(String name, String title)
563    {
564        Catalog newCatalog = null;
565        
566        ModifiableTraversableAmetysObject catalogsNode = getCatalogsRootNode();
567        
568        newCatalog = catalogsNode.createChild(name, "ametys:catalog");
569        newCatalog.setTitle(title);
570        
571        if (getCatalogs().size() == 1)
572        {
573            newCatalog.setDefault(true);
574        }
575        
576        newCatalog.saveChanges();
577        
578        return newCatalog;
579    }
580    
581    /**
582     * Get the programs of a catalog for all languages
583     * @param catalog The code of catalog
584     * @return The programs
585     */
586    public AmetysObjectIterable<Program> getPrograms (String catalog)
587    {
588        return getPrograms(catalog, null);
589    }
590    
591    /**
592     * Get the program's items of a catalog for all languages
593     * @param catalog The code of catalog
594     * @return The {@link ProgramItem}
595     */
596    private AmetysObjectIterable<Content> _getProgramItems(String catalog)
597    {
598        return _getContentsInCatalog(catalog, ProgramItem.PROGRAM_ITEM_CONTENT_TYPE);
599    }
600
601    /**
602     * Get the contents of a content type in a catalog.
603     * @param <T> The type of the elements to get
604     * @param catalog The catalog name
605     * @param contentTypeId The content type identifier
606     * @return An iterable of contents with given content type in the catalog
607     */
608    private <T extends Content> AmetysObjectIterable<T> _getContentsInCatalog(String catalog, String contentTypeId)
609    {
610        List<Expression> exprs = new ArrayList<>();
611        
612        exprs.add(_cTypeEP.createHierarchicalCTExpression(contentTypeId));
613        exprs.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
614        
615        Expression expression = new AndExpression(exprs.toArray(Expression[]::new));
616        
617        String query = ContentQueryHelper.getContentXPathQuery(expression);
618        return _resolver.query(query);
619    }
620    
621    /**
622     * Get the programs of a catalog
623     * @param catalog The code of catalog
624     * @param lang The language. Can be null to get programs for all languages
625     * @return The programs
626     */
627    public AmetysObjectIterable<Program> getPrograms (String catalog, String lang)
628    {
629        List<Expression> exprs = new ArrayList<>();
630        exprs.add(new ContentTypeExpression(Operator.EQ, ProgramFactory.PROGRAM_CONTENT_TYPE));
631        exprs.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
632        if (lang != null)
633        {
634            exprs.add(new LanguageExpression(Operator.EQ, lang));
635        }
636        
637        Expression programsExpression = new AndExpression(exprs.toArray(Expression[]::new));
638        
639        // Add sort criteria to get size
640        SortCriteria sortCriteria = new SortCriteria();
641        sortCriteria.addCriterion(Content.ATTRIBUTE_TITLE, true, true);
642        
643        String programsQuery = QueryHelper.getXPathQuery(null, ProgramFactory.PROGRAM_NODETYPE, programsExpression, sortCriteria);
644        return _resolver.query(programsQuery);
645    }
646    
647    /**
648     * Copy the programs and its hierarchy from a catalog to another.
649     * The referenced courses are NOT copied.
650     * @param catalog The new catalog to populate
651     * @param catalogToCopy The catalog from which we copy the programs.
652     * @param progressionTracker the progression tracker for catalog copy
653     * @throws ProcessingException If an error occurred during copy
654     */
655    public void copyCatalog(Catalog catalog, Catalog catalogToCopy, ContainerProgressionTracker progressionTracker) throws ProcessingException
656    {
657        String catalogToCopyName = catalogToCopy.getName();
658        String catalogName = catalog.getName();
659        String [] handledEventIds = new String[] {ObservationConstants.EVENT_CONTENT_ADDED, ObservationConstants.EVENT_CONTENT_MODIFIED,  ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED};
660        try
661        {
662            Map<Content, Content> copiedContents = new HashMap<>();
663            
664            Set<String> copyUpdaters = _copyUpdaterEP.getExtensionsIds();
665            
666            AmetysObjectIterable<Program> programs = getPrograms(catalogToCopyName);
667            
668            SimpleProgressionTracker copyStep = (SimpleProgressionTracker) progressionTracker.getCurrentStep();
669            copyStep.setSize(programs.getSize());
670            
671            // Do NOT commit yet to Solr in order to improve perfs
672            _solrIndexHelper.pauseSolrCommitForEvents(handledEventIds);
673            long start = System.currentTimeMillis();
674            
675            getLogger().debug("Begin to iterate over programs for copying them");
676            
677            for (Program program : programs)
678            {
679                if (getLogger().isDebugEnabled())
680                {
681                    getLogger().debug("Start copying program '{}' (name: '{}', title: '{}')...", program.getId(), program.getName(), program.getTitle());
682                }
683                
684                _odfHelper.copyProgramItem(program, catalogName, true, copiedContents);
685                copyStep.increment();
686            }
687
688            SimpleProgressionTracker updatesAfterCopy = (SimpleProgressionTracker) progressionTracker.getCurrentStep();
689            updatesAfterCopy.setSize(copyUpdaters.size());
690            
691            for (String updaterId : copyUpdaters)
692            {
693                // Call updaters after full copy of catalog
694                CopyCatalogUpdater updater = _copyUpdaterEP.getExtension(updaterId);
695                updater.updateContents(catalogToCopyName, catalogName, copiedContents, null);
696                updater.copyAdditionalContents(catalogToCopyName, catalogName, copiedContents);
697                
698                updatesAfterCopy.increment();
699            }
700
701            // Workflow
702            _addCopyStep(copiedContents.values(), (SimpleProgressionTracker) progressionTracker.getCurrentStep());
703            
704            if (getLogger().isDebugEnabled())
705            {
706                getLogger().debug("End of iteration over programs for copying them ({})", Duration.of((System.currentTimeMillis() - start) / 1000, ChronoUnit.SECONDS));
707            }
708            
709        }
710        catch (AmetysRepositoryException | WorkflowException e)
711        {
712            getLogger().error("Copy of items of catalog {} into catalog {} has failed", catalogToCopyName, catalogName);
713            throw new ProcessingException("Failed to copy catalog", e);
714        }
715        finally
716        {
717            SimpleProgressionTracker restartCommitStep = (SimpleProgressionTracker) progressionTracker.getCurrentStep();
718            restartCommitStep.setSize(1);
719            _solrIndexHelper.restartSolrCommitForEvents(handledEventIds);
720            restartCommitStep.increment();
721        }
722    }
723
724    private void _addCopyStep(Collection<Content> contents, SimpleProgressionTracker progressionTracker) throws AmetysRepositoryException, WorkflowException
725    {
726        progressionTracker.setSize(contents.size());
727        
728        for (Content content : contents)
729        {
730            if (content instanceof WorkflowAwareContent workflowAwareContent)
731            {
732                _contentWorkflowHelper.doAction(workflowAwareContent, getCopyActionId());
733            }
734            progressionTracker.increment();
735        }
736    }
737    
738    /**
739     * Get the workflow action id for copy.
740     * @return The workflow action id
741     */
742    protected int getCopyActionId()
743    {
744        return 210;
745    }
746    
747    /**
748     * Delete catalog
749     * @param catalog the catalog to delete
750     * @return the result map
751     */
752    public Map<String, Object> deleteCatalog(Catalog catalog)
753    {
754        Map<String, Object> result = new HashMap<>();
755        result.put("id", catalog.getId());
756        
757        String catalogName = catalog.getName();
758        List<Content> contentsToDelete = getContents(catalogName);
759        
760        // Before deleting anything, we have to make sure that it's safe to delete the catalog and its programItems
761        List<Content> referencingContents = _getExternalReferencingContents(contentsToDelete);
762        
763        if (!referencingContents.isEmpty())
764        {
765            for (Content content : referencingContents)
766            {
767                if (content instanceof ProgramItem || content instanceof CoursePart)
768                {
769                    getLogger().error("{} '{}' ({}) is referencing a content of the catalog {} while being itself in the catalog {}. There is an inconsistency.",
770                            content.getClass().getName(),
771                            content.getTitle(),
772                            content.getValue("code"),
773                            catalogName,
774                            content.getValue("catalog", false, StringUtils.EMPTY));
775                }
776                else
777                {
778                    getLogger().warn("Content {} ({}) is referencing a content of the catalog {}. There is an inconsistency.",
779                            content.getTitle(),
780                            content.getId(),
781                            catalogName);
782                }
783            }
784            result.put("error", "referencing-contents");
785            result.put("referencingContents", referencingContents);
786            return result;
787        }
788        
789        // Everything is fine, we can delete the courseParts, the programItems and the catalog
790        String[] handledEventIds = new String[] {ObservationConstants.EVENT_CONTENT_DELETED};
791        try
792        {
793            _solrIndexHelper.pauseSolrCommitForEvents(handledEventIds);
794            contentsToDelete.forEach(this::_deleteContent);
795        }
796        finally
797        {
798            _solrIndexHelper.restartSolrCommitForEvents(handledEventIds);
799        }
800        
801        ModifiableAmetysObject parent = catalog.getParent();
802        catalog.remove();
803        parent.saveChanges();
804        
805        return result;
806    }
807    
808    /**
809     * Get all the contents to delete when deleting a catalog.
810     * @param catalogName The catalog name
811     * @return a {@link Stream} of {@link Content} to delete
812     */
813    public List<Content> getContents(String catalogName)
814    {
815        List<Content> contents = new ArrayList<>();
816        contents.addAll(_getProgramItems(catalogName).stream().toList());
817        contents.addAll(_getCourseParts(catalogName).stream().toList());
818        
819        for (String updaterId : _copyUpdaterEP.getExtensionsIds())
820        {
821            CopyCatalogUpdater updater = _copyUpdaterEP.getExtension(updaterId);
822            contents.addAll(updater.getAdditionalContents(catalogName));
823        }
824        
825        return contents;
826    }
827    
828    /**
829     * Get the course part of a catalog for all languages
830     * @param catalog The code of catalog
831     * @return The {@link CoursePart}s
832     */
833    private AmetysObjectIterable<CoursePart> _getCourseParts(String catalog)
834    {
835        return _getContentsInCatalog(catalog, CoursePartFactory.COURSE_PART_CONTENT_TYPE);
836    }
837
838    /**
839     * Get the list of contents referencing one of the {@code ProgramItem}s in the set.
840     * This method will ignore content that are included in the set and any {@code CoursePart} belonging to a {@code Course} of the set
841     * @param contentsToDelete the contents to be tested
842     * @return the contents referencing those program items excluding the course parts id
843     */
844    private List<Content> _getExternalReferencingContents(List<Content> contentsToDelete)
845    {
846        
847        // Get all the Contents referencing one of the content to delete but not one of the content to delete
848        List<Content> referencingContents = contentsToDelete.stream()
849            .map(Content::getReferencingContents)   // get the referencing contents
850            .flatMap(Collection::stream)            // flatten the Collection
851            .distinct()                             // remove all duplicates
852            .filter(content -> !contentsToDelete.contains(content))
853            .collect(Collectors.toUnmodifiableList());          // collect it into a list
854        return referencingContents;
855    }
856    
857    private void _deleteContent(Content deletedContent)
858    {
859        try
860        {
861            RemovableAmetysObject content = _resolver.resolveById(deletedContent.getId());
862            
863            Map<String, Object> eventParams = new HashMap<>();
864            eventParams.put(ObservationConstants.ARGS_CONTENT, content);
865            eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
866            eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
867            ModifiableAmetysObject parent = content.getParent();
868            
869            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParams));
870            
871            // Remove the content.
872            LockableAmetysObject lockedContent = (LockableAmetysObject) content;
873            if (lockedContent.isLocked())
874            {
875                lockedContent.unlock();
876            }
877            
878            content.remove();
879            
880            parent.saveChanges();
881            
882            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams));
883        }
884        catch (UnknownAmetysObjectException e)
885        {
886            // Ignore, already removed
887            if (getLogger().isDebugEnabled())
888            {
889                getLogger().debug("An error occured while trying to delete the content '{}' while deleting the catalog.", deletedContent.getId(), e);
890            }
891        }
892    }
893    
894    private ModifiableTraversableAmetysObject _getOrCreateNode(ModifiableTraversableAmetysObject parentNode, String nodeName, String nodeType) throws AmetysRepositoryException
895    {
896        ModifiableTraversableAmetysObject definitionsNode;
897        if (parentNode.hasChild(nodeName))
898        {
899            definitionsNode = parentNode.getChild(nodeName);
900        }
901        else
902        {
903            definitionsNode = parentNode.createChild(nodeName, nodeType);
904            parentNode.saveChanges();
905        }
906        return definitionsNode;
907    }
908}