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.io.IOException;
019import java.time.Duration;
020import java.time.temporal.ChronoUnit;
021import java.util.ArrayList;
022import java.util.Collection;
023import java.util.Date;
024import java.util.HashMap;
025import java.util.List;
026import java.util.Map;
027import java.util.Set;
028import java.util.concurrent.ExecutionException;
029import java.util.concurrent.Future;
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;
042import org.apache.solr.client.solrj.SolrServerException;
043
044import org.ametys.cms.ObservationConstants;
045import org.ametys.cms.content.archive.ArchiveConstants;
046import org.ametys.cms.content.indexing.solr.SolrIndexer;
047import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
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.workflow.ContentWorkflowHelper;
055import org.ametys.core.observation.Event;
056import org.ametys.core.observation.ObservationManager;
057import org.ametys.core.ui.Callable;
058import org.ametys.core.user.CurrentUserProvider;
059import org.ametys.odf.ODFHelper;
060import org.ametys.odf.ProgramItem;
061import org.ametys.odf.course.Course;
062import org.ametys.odf.course.CourseContainer;
063import org.ametys.odf.courselist.CourseList;
064import org.ametys.odf.courselist.CourseListContainer;
065import org.ametys.odf.coursepart.CoursePart;
066import org.ametys.odf.program.Program;
067import org.ametys.odf.program.ProgramFactory;
068import org.ametys.odf.program.TraversableProgramPart;
069import org.ametys.plugins.repository.AmetysObject;
070import org.ametys.plugins.repository.AmetysObjectIterable;
071import org.ametys.plugins.repository.AmetysObjectResolver;
072import org.ametys.plugins.repository.AmetysRepositoryException;
073import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
074import org.ametys.plugins.repository.RepositoryConstants;
075import org.ametys.plugins.repository.TraversableAmetysObject;
076import org.ametys.plugins.repository.UnknownAmetysObjectException;
077import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
078import org.ametys.plugins.repository.query.QueryHelper;
079import org.ametys.plugins.repository.query.SortCriteria;
080import org.ametys.plugins.repository.query.expression.AndExpression;
081import org.ametys.plugins.repository.query.expression.Expression;
082import org.ametys.plugins.repository.query.expression.Expression.Operator;
083import org.ametys.plugins.repository.query.expression.StringExpression;
084import org.ametys.runtime.plugin.component.AbstractLogEnabled;
085import org.ametys.runtime.plugin.component.PluginAware;
086
087import com.opensymphony.workflow.WorkflowException;
088
089/**
090 * Component to handle ODF catalogs
091 */
092public class CatalogsManager extends AbstractLogEnabled implements Serviceable, Component, PluginAware, Contextualizable
093{
094    /** Avalon Role */
095    public static final String ROLE = CatalogsManager.class.getName();
096
097    private AmetysObjectResolver _resolver;
098
099    private CopyCatalogUpdaterExtensionPoint _copyUpdaterEP;
100
101    private ObservationManager _observationManager;
102
103    private CurrentUserProvider _userProvider;
104
105    private ContentWorkflowHelper _contentWorkflowHelper;
106
107    private String _pluginName;
108
109    private ODFHelper _odfHelper;
110
111    private ContentTypeExtensionPoint _cTypeEP;
112    
113    private Context _context;
114
115    private SolrIndexer _solrIndexer;
116    
117    public void contextualize(Context context) throws ContextException
118    {
119        _context = context;
120    }
121    
122    @Override
123    public void service(ServiceManager manager) throws ServiceException
124    {
125        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
126        _copyUpdaterEP = (CopyCatalogUpdaterExtensionPoint) manager.lookup(CopyCatalogUpdaterExtensionPoint.ROLE);
127        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
128        _userProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
129        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
130        _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE);
131        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
132        _solrIndexer = (SolrIndexer) manager.lookup(SolrIndexer.ROLE);
133    }
134    
135    public void setPluginInfo(String pluginName, String featureName, String id)
136    {
137        _pluginName = pluginName;
138    }
139    
140    /**
141     * Get the list of catalogs
142     * @return the catalogs
143     */
144    public List<Catalog> getCatalogs()
145    {
146        List<Catalog> result = new ArrayList<>();
147
148        TraversableAmetysObject catalogsNode = getCatalogsRootNode();
149        
150        AmetysObjectIterable<Catalog> catalogs = catalogsNode.getChildren();
151        for (Catalog catalog : catalogs)
152        {
153            result.add(catalog);
154        }
155        
156        return result;
157    }
158
159    /**
160     * Get a catalog matching with the given name
161     * @param name The name
162     * @return a catalog, or null if not found
163     */
164    public Catalog getCatalog(String name)
165    {
166        ModifiableTraversableAmetysObject catalogsNode = getCatalogsRootNode();
167        
168        if (StringUtils.isNotEmpty(name) && catalogsNode.hasChild(name))
169        {
170            return catalogsNode.getChild(name);
171        }
172        
173        // Not found
174        return null;
175    }
176    
177    /**
178     * Returns the name of the default catalog
179     * @return the name of the default catalog
180     */
181    @Callable
182    public String getDefaultCatalogName()
183    {
184        Catalog defaultCatalog = getDefaultCatalog();
185        return defaultCatalog != null ? defaultCatalog.getName() : null;
186    }
187    
188    /**
189     * Returns the default catalog
190     * @return the default catalog or null if no default catalog was defined.
191     */
192    public Catalog getDefaultCatalog()
193    {
194        List<Catalog> catalogs = getCatalogs();
195        for (Catalog catalog : catalogs)
196        {
197            if (catalog.isDefault())
198            {
199                return catalog;
200            }
201        }
202        return null;
203    }
204    
205    /**
206     * Get the name of the catalog of a ODF content
207     * @param contentId The id of content
208     * @return The catalog's name
209     */
210    @Callable
211    public String getContentCatalog(String contentId)
212    {
213        Content content = _resolver.resolveById(contentId);
214        
215        if (content instanceof ProgramItem)
216        {
217            return ((ProgramItem) content).getCatalog();
218        }
219        
220        // Get catalog from its parents (unecessary ?)
221        AmetysObject parent = content.getParent();
222        while (parent != null)
223        {
224            if (parent instanceof ProgramItem)
225            {
226                return ((ProgramItem) parent).getCatalog();
227            }
228            parent = parent.getParent();
229        }
230        
231        return null;
232    }
233    
234    /**
235     * Set the catalog of a content. This will modify recursively the catalog of referenced children
236     * @param catalog The catalog
237     * @param contentId The id of content to edit
238     * @throws WorkflowException if an error occurred
239     */
240    @Callable
241    public void setContentCatalog(String catalog, String contentId) throws WorkflowException
242    {
243        Content content = _resolver.resolveById(contentId);
244        
245        if (content instanceof ProgramItem)
246        {
247            _setCatalog(content, catalog);
248        }
249        else
250        {
251            throw new IllegalArgumentException("You can not edit the catalog of the content " + contentId);
252        }
253    }
254    
255    private void _setCatalog (Content content, String catalogName) throws WorkflowException
256    {
257        if (content instanceof ProgramItem)
258        {
259            String oldCatalog = ((ProgramItem) content).getCatalog();
260            if (!catalogName.equals(oldCatalog))
261            {
262                ((ProgramItem) content).setCatalog(catalogName);
263                
264                if (content instanceof WorkflowAwareContent)
265                {
266                    _applyChanges((WorkflowAwareContent) content);
267                }
268            }
269        }
270        else if (content instanceof CoursePart)
271        {
272            String oldCatalog = ((CoursePart) content).getCatalog();
273            if (!catalogName.equals(oldCatalog))
274            {
275                ((CoursePart) content).setCatalog(catalogName);
276                
277                if (content instanceof WorkflowAwareContent)
278                {
279                    _applyChanges((WorkflowAwareContent) content);
280                }
281            }
282        }
283        
284        _setCatalogToChildren(content, catalogName);
285    }
286    
287    private void _setCatalogToChildren (Content content, String catalogName) throws WorkflowException
288    {
289        if (content instanceof TraversableProgramPart)
290        {
291            String[] children = content.getMetadataHolder().getStringArray(TraversableProgramPart.METADATA_CHILD_PROGRAM_PARTS, new String[0]);
292            for (String id : children)
293            {
294                try
295                {
296                    Content child = _resolver.resolveById(id);
297                    _setCatalog(child, catalogName);
298                }
299                catch (UnknownAmetysObjectException e)
300                {
301                    // Nothing
302                }
303            }
304        }
305        
306        if (content instanceof CourseContainer)
307        {
308            for (Course course : ((CourseContainer) content).getCourses())
309            {
310                _setCatalog(course, catalogName);
311            }
312        }
313        
314        if (content instanceof CourseListContainer)
315        {
316            for (CourseList cl : ((CourseListContainer) content).getCourseLists())
317            {
318                _setCatalog(cl, catalogName);
319            }
320        }
321        
322        if (content instanceof Course)
323        {
324            for (CoursePart coursePart : ((Course) content).getCourseParts())
325            {
326                _setCatalog(coursePart, catalogName);
327            }
328        }
329    }
330    
331    private void _applyChanges(WorkflowAwareContent content) throws WorkflowException
332    {
333        ((ModifiableDefaultContent) content).setLastContributor(_userProvider.getUser());
334        ((ModifiableDefaultContent) content).setLastModified(new Date());
335        
336        // Remove the proposal date.
337        content.setProposalDate(null);
338        
339        // Save changes
340        content.saveChanges();
341        
342        // Notify listeners
343        Map<String, Object> eventParams = new HashMap<>();
344        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
345        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
346        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _userProvider.getUser(), eventParams));
347       
348        _contentWorkflowHelper.doAction(content, 22);
349    }
350    
351    /**
352     * Get the root catalogs storage object.
353     * @return the root catalogs node
354     * @throws AmetysRepositoryException if a repository error occurs.
355     */
356    public ModifiableTraversableAmetysObject getCatalogsRootNode() throws AmetysRepositoryException
357    {
358        String originalWorkspace = null;
359        Request request = ContextHelper.getRequest(_context);
360        try
361        {
362            originalWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
363            if (ArchiveConstants.ARCHIVE_WORKSPACE.equals(originalWorkspace))
364            {
365                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, RepositoryConstants.DEFAULT_WORKSPACE);
366            }
367            
368            ModifiableTraversableAmetysObject rootNode = _resolver.resolveByPath("/");
369            ModifiableTraversableAmetysObject pluginsNode = _getOrCreateNode(rootNode, "ametys:plugins", "ametys:unstructured");
370            ModifiableTraversableAmetysObject pluginNode = _getOrCreateNode(pluginsNode, _pluginName, "ametys:unstructured");
371            
372            return _getOrCreateNode(pluginNode, "catalogs", "ametys:unstructured");
373        }
374        catch (AmetysRepositoryException e)
375        {
376            throw new AmetysRepositoryException("Unable to get the ODF catalogs root node", e);
377        }
378        finally
379        {
380            if (ArchiveConstants.ARCHIVE_WORKSPACE.equals(originalWorkspace))
381            {
382                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, originalWorkspace);
383            }
384        }
385    }
386    
387    /**
388     * Create a new catalog
389     * @param name The unique name
390     * @param title The title of catalog
391     * @return the created catalog
392     */
393    public Catalog createCatalog(String name, String title)
394    {
395        Catalog newCatalog = null;
396        
397        ModifiableTraversableAmetysObject catalogsNode = getCatalogsRootNode();
398        
399        newCatalog = catalogsNode.createChild(name, "ametys:catalog");
400        newCatalog.setTitle(title);
401        
402        if (getCatalogs().size() == 1)
403        {
404            newCatalog.setDefault(true);
405        }
406        return newCatalog;
407    }
408    
409    /**
410     * Get the programs of a catalog for all languages
411     * @param catalog The code of catalog
412     * @return The programs
413     */
414    public AmetysObjectIterable<Program> getPrograms (String catalog)
415    {
416        return getPrograms(catalog, null);
417    }
418    
419    /**
420     * Get the program's items of a catalog for all languages
421     * @param catalog The code of catalog
422     * @return The {@link ProgramItem}
423     */
424    public AmetysObjectIterable<ProgramItem> getProgramItems(String catalog)
425    {
426        List<Expression> exprs = new ArrayList<>();
427        
428        exprs.add(_cTypeEP.createHierarchicalCTExpression(ProgramItem.PROGRAM_ITEM_CONTENT_TYPE));
429        exprs.add(new StringExpression(ProgramItem.METADATA_CATALOG, Operator.EQ, catalog));
430        
431        Expression programItemsExpression = new AndExpression(exprs.toArray(new Expression[exprs.size()]));
432        
433        // Add sort criteria to get size
434        SortCriteria sortCriteria = new SortCriteria();
435        sortCriteria.addCriterion(Content.METADATA_TITLE, true, true);
436        
437        String query = ContentQueryHelper.getContentXPathQuery(programItemsExpression, sortCriteria);
438        return _resolver.query(query);
439    }
440    
441    /**
442     * Get the programs of a catalog
443     * @param catalog The code of catalog
444     * @param lang The language. Can be null to get programs for all languages
445     * @return The programs
446     */
447    public AmetysObjectIterable<Program> getPrograms (String catalog, String lang)
448    {
449        List<Expression> exprs = new ArrayList<>();
450        exprs.add(new ContentTypeExpression(Operator.EQ, ProgramFactory.PROGRAM_CONTENT_TYPE));
451        exprs.add(new StringExpression(ProgramItem.METADATA_CATALOG, Operator.EQ, catalog));
452        if (lang != null)
453        {
454            exprs.add(new LanguageExpression(Operator.EQ, lang));
455        }
456        
457        Expression programsExpression = new AndExpression(exprs.toArray(new Expression[exprs.size()]));
458        
459        // Add sort criteria to get size
460        SortCriteria sortCriteria = new SortCriteria();
461        sortCriteria.addCriterion(Content.METADATA_TITLE, true, true);
462        
463        String programsQuery = QueryHelper.getXPathQuery(null, ProgramFactory.PROGRAM_NODETYPE, programsExpression, sortCriteria);
464        return _resolver.query(programsQuery);
465    }
466    
467    /**
468     * Copy the programs and its hierarchy from a catalog to another.
469     * The referenced courses are NOT copied.
470     * @param catalog The new catalog to populate
471     * @param catalogToCopy The catalog from which we copy the programs.
472     * @throws ProcessingException If an error occurred during copy
473     */
474    public void copyCatalog(Catalog catalog, Catalog catalogToCopy) throws ProcessingException
475    {
476        String catalogToCopyName = catalogToCopy.getName();
477        String catalogName = catalog.getName();
478        String [] handledEventIds = new String[] {ObservationConstants.EVENT_CONTENT_ADDED, ObservationConstants.EVENT_CONTENT_MODIFIED,  ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED};
479        try
480        {
481            Map<String, String> copiedPrograms = new HashMap<>();
482            Map<String, String> copiedSubPrograms = new HashMap<>();
483            Map<String, String> copiedContainers = new HashMap<>();
484            Map<String, String> copiedCourseLists = new HashMap<>();
485            Map<String, String> copiedCourses = new HashMap<>();
486            Map<String, String> copiedCourseParts = new HashMap<>();
487            
488            Set<String> copyUpdaters = _copyUpdaterEP.getExtensionsIds();
489            
490            AmetysObjectIterable<Program> programs = getPrograms(catalogToCopyName);
491            
492            // Do NOT commit yet to Solr in order to improve perfs
493            _observationManager.addArgumentForEvents(handledEventIds, ObservationConstants.ARGS_CONTENT_COMMIT, false);
494
495            long start = System.currentTimeMillis();
496            
497            getLogger().debug("Begin to iterate over programs for copying them");
498            
499            for (Program program : programs)
500            {
501                if (getLogger().isDebugEnabled())
502                {
503                    getLogger().debug("Start copying program '{}' (name: '{}', title: '{}')...", program.getId(), program.getName(), program.getTitle());
504                }
505                
506                Program newProgram = _odfHelper.copyProgramItem(program, catalogName, true, copiedPrograms, copiedSubPrograms, copiedContainers, copiedCourseLists, copiedCourses, copiedCourseParts);
507                
508                for (String updaterId : copyUpdaters)
509                {
510                    // Call updaters after copy of program
511                    CopyCatalogUpdater updater = _copyUpdaterEP.getExtension(updaterId);
512                    updater.updateContent(catalogToCopyName, catalogName, program, newProgram);
513                }
514            }
515
516            for (String updaterId : copyUpdaters)
517            {
518                // Call updaters after full copy of catalog
519                CopyCatalogUpdater updater = _copyUpdaterEP.getExtension(updaterId);
520                updater.updateContents(catalogToCopyName, catalogName, copiedPrograms, copiedSubPrograms, copiedContainers, copiedCourseLists, copiedCourses, copiedCourseParts);
521            }
522            
523            // Workflow
524            _addCopyStep(copiedPrograms.values());
525            _addCopyStep(copiedSubPrograms.values());
526            _addCopyStep(copiedContainers.values());
527            _addCopyStep(copiedCourseLists.values());
528            _addCopyStep(copiedCourses.values());
529            _addCopyStep(copiedCourseParts.values());
530
531            if (getLogger().isDebugEnabled())
532            {
533                getLogger().debug("End of iteration over programs for copying them ({})", Duration.of((System.currentTimeMillis() - start) / 1000, ChronoUnit.SECONDS));
534            }
535            
536        }
537        catch (AmetysRepositoryException | WorkflowException e)
538        {
539            getLogger().error("Copy of items of catalog {} into catalog {} has failed", catalogToCopyName, catalogName);
540            throw new ProcessingException("Failed to copy catalog", e);
541        }
542        finally
543        {
544            _observationManager.removeArgumentForEvents(handledEventIds, ObservationConstants.ARGS_CONTENT_COMMIT);
545            
546            // Before trying to commit, be sure all the async observers of the current request are finished
547            for (Future future : _observationManager.getFuturesForRequest())
548            {
549                try
550                {
551                    future.get();
552                }
553                catch (ExecutionException | InterruptedException e)
554                {
555                    getLogger().info("An exception occured when calling #get() on Future result of an observer." , e);
556                }
557            }
558            
559            // Commit all uncommited changes
560            try
561            {
562                _solrIndexer.commit();
563                
564                getLogger().debug("Copied contents are now committed into Solr.");
565            }
566            catch (IOException | SolrServerException e)
567            {
568                getLogger().error("Impossible to commit changes", e);
569            }
570        }
571    }
572    
573    private void _addCopyStep(Collection<String> contentIds) throws AmetysRepositoryException, WorkflowException
574    {
575        for (String contentId : contentIds)
576        {
577            WorkflowAwareContent content = _resolver.resolveById(contentId);
578            _contentWorkflowHelper.doAction(content, getCopyActionId());
579        }
580    }
581    
582    /**
583     * Get the workflow action id for copy.
584     * @return The workflow action id
585     */
586    protected int getCopyActionId()
587    {
588        return 210;
589    }
590    
591    /**
592     * Delete catalog
593     * @param id the id of catalog
594     */
595    public void deleteCatalog (String id)
596    {
597        try
598        {
599            Catalog catalog = _resolver.resolveById(id);
600            if (catalog != null)
601            {
602                catalog.remove();
603                catalog.saveChanges();
604            }
605        }
606        catch (UnknownAmetysObjectException e)
607        {
608            // Nothing
609        }
610        
611    }
612    
613    private ModifiableTraversableAmetysObject _getOrCreateNode(ModifiableTraversableAmetysObject parentNode, String nodeName, String nodeType) throws AmetysRepositoryException
614    {
615        ModifiableTraversableAmetysObject definitionsNode;
616        if (parentNode.hasChild(nodeName))
617        {
618            definitionsNode = parentNode.getChild(nodeName);
619        }
620        else
621        {
622            definitionsNode = parentNode.createChild(nodeName, nodeType);
623            parentNode.saveChanges();
624        }
625        return definitionsNode;
626    }
627}