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