001/*
002 *  Copyright 2019 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.migration;
017
018import java.util.ArrayList;
019import java.util.HashMap;
020import java.util.HashSet;
021import java.util.Map;
022import java.util.Optional;
023import java.util.Set;
024import java.util.stream.Collectors;
025
026import javax.jcr.RepositoryException;
027
028import org.apache.avalon.framework.component.Component;
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.avalon.framework.service.Serviceable;
032import org.apache.commons.lang3.ArrayUtils;
033import org.apache.commons.lang3.tuple.Pair;
034
035import org.ametys.cms.CmsConstants;
036import org.ametys.cms.data.ContentDataHelper;
037import org.ametys.cms.repository.ContentQueryHelper;
038import org.ametys.cms.repository.ContentTypeExpression;
039import org.ametys.cms.repository.ModifiableDefaultContent;
040import org.ametys.cms.repository.WorkflowAwareContent;
041import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
042import org.ametys.cms.workflow.ContentWorkflowHelper;
043import org.ametys.cms.workflow.CreateContentFunction;
044import org.ametys.odf.course.Course;
045import org.ametys.odf.course.CourseFactory;
046import org.ametys.odf.coursepart.CoursePart;
047import org.ametys.odf.coursepart.CoursePartFactory;
048import org.ametys.odf.enumeration.OdfReferenceTableEntry;
049import org.ametys.odf.enumeration.OdfReferenceTableHelper;
050import org.ametys.plugins.repository.AmetysObjectIterable;
051import org.ametys.plugins.repository.AmetysObjectResolver;
052import org.ametys.plugins.repository.AmetysRepositoryException;
053import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
054import org.ametys.plugins.repository.data.repositorydata.impl.JCRRepositoryData;
055import org.ametys.plugins.repository.jcr.NameHelper;
056import org.ametys.plugins.repository.query.expression.AndExpression;
057import org.ametys.plugins.repository.query.expression.Expression;
058import org.ametys.plugins.repository.query.expression.Expression.Operator;
059import org.ametys.plugins.repository.query.expression.MetadataExpression;
060import org.ametys.plugins.repository.query.expression.OrExpression;
061import org.ametys.plugins.workflow.AbstractWorkflowComponent;
062import org.ametys.plugins.workflow.support.WorkflowProvider;
063import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
064import org.ametys.runtime.plugin.component.AbstractLogEnabled;
065
066import com.opensymphony.workflow.InvalidActionException;
067import com.opensymphony.workflow.WorkflowException;
068
069/**
070 * Component class to migrate totalDurationOf* metadata.
071 */
072public class MigrateCoursePartComponent extends AbstractLogEnabled implements Serviceable, Component
073{
074    /** The component role. */
075    public static final String ROLE = MigrateCoursePartComponent.class.getName();
076    
077    /** The Ametys object resolver */
078    protected AmetysObjectResolver _resolver;
079    /** The ODF Reference table helper */
080    protected OdfReferenceTableHelper _odfRefTableHelper;
081    /** The content workflow helper */
082    protected ContentWorkflowHelper _workflowHelper;
083    /** The workflow */
084    protected WorkflowProvider _workflowProvider;
085
086    @Override
087    public void service(ServiceManager manager) throws ServiceException
088    {
089        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
090        _odfRefTableHelper = (OdfReferenceTableHelper) manager.lookup(OdfReferenceTableHelper.ROLE);
091        _workflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
092        _workflowProvider = (WorkflowProvider) manager.lookup(WorkflowProvider.ROLE);
093    }
094
095    /**
096     * Migrate the totalDurationOf to course parts.
097     * @throws AmetysRepositoryException if an error occurs
098     * @throws WorkflowException if an error occurs
099     * @throws RepositoryException if an error occurs
100     */
101    public void migrateCourseParts() throws AmetysRepositoryException, WorkflowException, RepositoryException
102    {
103        migrateCourseParts(null, null);
104    }
105    
106    /**
107     * Migrate the totalDurationOf to course parts.
108     * @param natureEnseignementCategories the map of nature enseignement categories
109     * @param natureEnseignements the map of nature enseignement
110     * @throws AmetysRepositoryException if an error occurs
111     * @throws WorkflowException if an error occurs
112     * @throws RepositoryException if an error occurs
113     */
114    public void migrateCourseParts(Map<String, Pair<String, Long>> natureEnseignementCategories, Map<String, Pair<String, String>> natureEnseignements) throws AmetysRepositoryException, WorkflowException, RepositoryException
115    {
116        Map<String, OdfReferenceTableEntry> natureByCode = _createNaturesEnseignement(natureEnseignementCategories, natureEnseignements);
117        
118        // Migrate existing totalDurationOf*
119        Expression[] metadataExpressions =  natureByCode.keySet()
120            .stream()
121            .map(code -> new MetadataExpression("totalDurationOf" + code))
122            .toArray(MetadataExpression[]::new);
123        
124        String xpathQuery = ContentQueryHelper.getContentXPathQuery(
125            new AndExpression(
126                new ContentTypeExpression(Operator.EQ, CourseFactory.COURSE_CONTENT_TYPE),
127                new OrExpression(metadataExpressions)
128            ));
129        
130        AmetysObjectIterable<Course> courses = _resolver.query(xpathQuery);
131        for (Course course : courses)
132        {
133            boolean hasLiveVersion = ArrayUtils.contains(course.getAllLabels(), CmsConstants.LIVE_LABEL);
134            boolean currentVersionIsLive = hasLiveVersion && ArrayUtils.contains(course.getLabels(), CmsConstants.LIVE_LABEL);
135            
136            if (hasLiveVersion && !currentVersionIsLive)
137            {
138                String currentVersion = course.getNode()
139                    .getSession()
140                    .getWorkspace()
141                    .getVersionManager()
142                    .getBaseVersion(course.getNode().getPath())
143                    .getName();
144                
145                // switching to old live version
146                course.restoreFromLabel(CmsConstants.LIVE_LABEL);
147                
148                // migrate old live version
149                Map<String, CoursePart> createdCourseParts = _migrateCoursePart(course, natureByCode, true);
150                
151                // restore current version
152                course.restoreFromRevision(currentVersion);
153                
154                // update current version
155                _updateCoursePart(course, natureByCode, createdCourseParts);
156            }
157            else
158            {
159                // migrate current version
160                _migrateCoursePart(course, natureByCode, currentVersionIsLive);
161            }
162        }
163    }
164    
165    /**
166     * Update the {@link CoursePart} and the course parts list of the {@link Course} with the values of the current version.
167     * @param course The {@link Course} to migrate
168     * @param natureByCode The course part natures
169     * @param createdCourseParts The {@link Map} of the created {@link CoursePart} previously
170     */
171    protected void _updateCoursePart(Course course, Map<String, OdfReferenceTableEntry> natureByCode, Map<String, CoursePart> createdCourseParts)
172    {
173        JCRRepositoryData courseRepoData = new JCRRepositoryData(course.getNode());
174        
175        Set<String> courseParts = new HashSet<>();
176        
177        for (String natureCode : natureByCode.keySet())
178        {
179            String dataName = "totalDurationOf" + natureCode;
180            double totalDurationOf = courseRepoData.hasValue(dataName) ? courseRepoData.getDouble(dataName) : 0;
181            
182            if (totalDurationOf > 0)
183            {
184                CoursePart coursePart = createdCourseParts.get(natureCode);
185                if (coursePart == null)
186                {
187                    // Create course part
188                    Optional.ofNullable(_createCoursePart(course, natureByCode.get(natureCode), totalDurationOf, false))
189                        .ifPresent(createdCoursePart -> courseParts.add(createdCoursePart.getId()));
190                }
191                else
192                {
193                    // Update course part
194                    double oldValue = coursePart.getValue(CoursePart.NB_HOURS, false, 0D);
195                    if (oldValue != totalDurationOf)
196                    {
197                        coursePart.setValue(CoursePart.NB_HOURS, totalDurationOf);
198                        coursePart.saveChanges();
199                        coursePart.checkpoint();
200                    }
201                    
202                    courseParts.add(coursePart.getId());
203                }
204            }
205            
206            removeExternalizableData(courseRepoData, dataName);
207        }
208        
209        // Add the course part to the course
210        if (!courseParts.isEmpty())
211        {
212            courseParts.addAll(ContentDataHelper.getContentIdsListFromMultipleContentData(course, Course.CHILD_COURSE_PARTS));
213            course.setValue(Course.CHILD_COURSE_PARTS, courseParts.toArray(new String[courseParts.size()]));
214        }
215        else
216        {
217            course.removeValue(Course.CHILD_COURSE_PARTS);
218            course.removeExternalizableMetadataIfExists(Course.CHILD_COURSE_PARTS);
219        }
220        
221        if (course.needsSave())
222        {
223            course.saveChanges();
224            course.checkpoint();
225        }
226    }
227    
228    /**
229     * Create a course part linked to the course.
230     * @param course The {@link Course} to migrate
231     * @param natureByCode The course part natures
232     * @param isLive Set the Live label if <code>true</code>
233     * @return The created course parts
234     */
235    protected Map<String, CoursePart> _migrateCoursePart(Course course, Map<String, OdfReferenceTableEntry> natureByCode, boolean isLive)
236    {
237        JCRRepositoryData courseRepoData = new JCRRepositoryData(course.getNode());
238        
239        Map<String, CoursePart> createdCourseParts = new HashMap<>();
240        
241        // Create the course parts
242        for (String natureCode : natureByCode.keySet())
243        {
244            String dataName = "totalDurationOf" + natureCode;
245            double totalDurationOf = courseRepoData.hasValue(dataName) ? courseRepoData.getDouble(dataName) : 0;
246            
247            if (totalDurationOf > 0)
248            {
249                Optional.ofNullable(_createCoursePart(course, natureByCode.get(natureCode), totalDurationOf, isLive))
250                    .ifPresent(coursePart -> createdCourseParts.put(natureCode, coursePart));
251            }
252
253            removeExternalizableData(courseRepoData, dataName);
254        }
255        
256        // Add the course part to the course
257        if (!createdCourseParts.isEmpty())
258        {
259            Set<String> coursePartIds = createdCourseParts.values()
260                .stream()
261                .map(CoursePart::getId)
262                .collect(Collectors.toSet());
263            
264            coursePartIds.addAll(ContentDataHelper.getContentIdsListFromMultipleContentData(course, Course.CHILD_COURSE_PARTS));
265            course.setValue(Course.CHILD_COURSE_PARTS, coursePartIds.toArray(new String[coursePartIds.size()]));
266        }
267        
268        if (course.needsSave())
269        {
270            // Versions précédentes incompatibles
271            course.addLabel("NotCompatible", true);
272            
273            // Sauvegarde et avancement
274            course.saveChanges();
275            course.checkpoint();
276        }
277        
278        if (isLive)
279        {
280            course.addLabel(CmsConstants.LIVE_LABEL, true);
281        }
282        
283        return createdCourseParts;
284    }
285    
286    /**
287     * Remove a data and its externalizable data
288     * @param repositoryData the repository data containing the data
289     * @param dataName name of the data to remove
290     */
291    protected void removeExternalizableData(JCRRepositoryData repositoryData, String dataName)
292    {
293        if (repositoryData.hasValue(dataName))
294        {
295            repositoryData.removeValue(dataName);
296        }
297        
298        if (repositoryData.hasValue(dataName + ModelAwareDataHolder.ALTERNATIVE_SUFFIX))
299        {
300            repositoryData.removeValue(dataName + ModelAwareDataHolder.ALTERNATIVE_SUFFIX);
301        }
302        
303        if (repositoryData.hasValue(dataName + ModelAwareDataHolder.STATUS_SUFFIX))
304        {
305            repositoryData.removeValue(dataName + ModelAwareDataHolder.STATUS_SUFFIX);
306        }
307    }
308    
309    /**
310     * Create a course part linked to the course.
311     * @param course The {@link Course} holder
312     * @param nature The nature of the course part
313     * @param totalDurationOf The number of hours
314     * @param isLive Set the Live label if <code>true</code>
315     * @return The {@link CoursePart} id
316     */
317    protected CoursePart _createCoursePart(Course course, OdfReferenceTableEntry nature, Double totalDurationOf, boolean isLive)
318    {
319     // Create the course part
320        String coursePartTitle = course.getTitle();
321        if (nature != null)
322        {
323            coursePartTitle += " - " + nature.getCode();
324        }
325        String coursePartName = NameHelper.filterName(coursePartTitle);
326        
327        Map<String, Object> resultMap = new HashMap<>();
328        
329        Map<String, Object> inputs = new HashMap<>();
330        inputs.put(CreateContentFunction.CONTENT_LANGUAGE_KEY, course.getLanguage());
331        inputs.put(CreateContentFunction.CONTENT_NAME_KEY, coursePartName); 
332        inputs.put(CreateContentFunction.CONTENT_TITLE_KEY, coursePartTitle);
333        inputs.put(CreateContentFunction.CONTENT_TYPES_KEY, new String[] {CoursePartFactory.COURSE_PART_CONTENT_TYPE});
334        
335        Map<String, Object> results = new HashMap<>();
336        inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, results);
337        
338        CoursePart coursePart = null;
339        try
340        {
341            Map<String, Object> workflowResult = _workflowHelper.createContent("course-part", 1, coursePartName, coursePartTitle, new String[] {CoursePartFactory.COURSE_PART_CONTENT_TYPE}, null, course.getLanguage());
342            coursePart = (CoursePart) workflowResult.get(AbstractContentWorkflowComponent.CONTENT_KEY);
343            
344            if (nature != null)
345            {
346                coursePart.setValue(CoursePart.NATURE, nature.getId());
347            }
348            coursePart.setValue(CoursePart.NB_HOURS, totalDurationOf);
349            coursePart.setValue(CoursePart.COURSE_HOLDER, course.getId());
350            coursePart.setValue(CoursePart.PARENT_COURSES, new String[] {course.getId()});
351            coursePart.setValue(CoursePart.CATALOG, course.getCatalog());
352            
353            _setAdditionalValues(coursePart, course);
354            
355            coursePart.saveChanges();
356            coursePart.checkpoint();
357            
358            if (isLive)
359            {
360                coursePart.addLabel(CmsConstants.LIVE_LABEL, true);
361            }
362        }
363        catch (WorkflowException e)
364        {
365            resultMap.put("error", Boolean.TRUE);
366            getLogger().error("Failed to initialize workflow for content '{}' and language '{}'", coursePartTitle, course.getLanguage(), e);
367        }
368        
369        return coursePart;
370    }
371    
372    /**
373     * Set additional values to the {@link CoursePart} from the {@link Course}.
374     * @param coursePart The course part to modify
375     * @param course The original course
376     */
377    protected void _setAdditionalValues(CoursePart coursePart, Course course)
378    {
379        // Nothing to do
380    }
381    
382    /**
383     * Create entries into the reference table EnseignementNature if they don't exist.
384     * @param natureEnseignementCategories the map of nature enseignement categories
385     * @param natureEnseignements the map of nature enseignement
386     * @return The list of natures with their code and associated ID.
387     * @throws AmetysRepositoryException if an error occurs
388     * @throws WorkflowException if an error occurs
389     */
390    protected Map<String, OdfReferenceTableEntry> _createNaturesEnseignement(Map<String, Pair<String, Long>> natureEnseignementCategories, Map<String, Pair<String, String>> natureEnseignements) throws AmetysRepositoryException, WorkflowException
391    {
392        Map<String, String> categoryByCode = _createNatureEnseignementCategories(natureEnseignementCategories);
393        Map<String, OdfReferenceTableEntry> natureByCode = new HashMap<>();
394        
395        for (Map.Entry<String, Pair<String, String>> nature : _getNaturesEnseignementList(natureEnseignements).entrySet())
396        {
397            String natureCode = nature.getKey();
398            OdfReferenceTableEntry natureEntry = _odfRefTableHelper.getItemFromCode(OdfReferenceTableHelper.ENSEIGNEMENT_NATURE, natureCode);
399            
400            if (natureEntry == null)
401            {
402                String title = nature.getValue().getLeft();
403                String categoryCode = nature.getValue().getRight();
404                Map<String, String> titleVariants = new HashMap<>();
405                titleVariants.put("fr", title);
406                Map<String, Object> result = _workflowHelper.createContent("reference-table", 1, title, titleVariants, new String[] {OdfReferenceTableHelper.ENSEIGNEMENT_NATURE}, new String[0]);
407                ModifiableDefaultContent natureContent = (ModifiableDefaultContent) result.get(AbstractContentWorkflowComponent.CONTENT_KEY);
408                natureContent.setValue(OdfReferenceTableEntry.CODE, natureCode);
409                natureContent.setValue("category", categoryByCode.get(categoryCode));
410                natureContent.saveChanges();
411                _doAction(natureContent, 22);
412                
413                natureEntry = new OdfReferenceTableEntry(natureContent);
414            }
415            
416            natureByCode.put(natureCode, natureEntry);
417        }
418        
419        return natureByCode;
420    }
421
422    /**
423     * Create entries into the reference table EnseignementNature if they don't exist.
424     * @param natureEnseignementCategories the map of nature enseignement categories
425     * @return The list of natures with their code and associated ID.
426     * @throws AmetysRepositoryException if an error occurs
427     * @throws WorkflowException if an error occurs
428     */
429    protected Map<String, String> _createNatureEnseignementCategories(Map<String, Pair<String, Long>> natureEnseignementCategories) throws AmetysRepositoryException, WorkflowException
430    {
431        Map<String, String> categoryByCode = new HashMap<>();
432        
433        for (Map.Entry<String, Pair<String, Long>> category : _getNaturesEnseignementCategoryList(natureEnseignementCategories).entrySet())
434        {
435            String categoryCode = category.getKey();
436            OdfReferenceTableEntry categoryEntry = _getOrCreateNatureEnseignement(category.getValue().getLeft(), categoryCode, category.getValue().getRight());
437            categoryByCode.put(categoryCode, categoryEntry.getId());
438        }
439        
440        return categoryByCode;
441    }
442    
443    /**
444     * List of the course parts natures.
445     * @param natureEnseignements the map of nature enseignement
446     * @return A {@link Map} with the code as a key, and a {@link Pair} with the title and the category code as a value
447     */
448    protected Map<String, Pair<String, String>> _getNaturesEnseignementList(Map<String, Pair<String, String>> natureEnseignements)
449    {
450        if (natureEnseignements != null && !natureEnseignements.isEmpty())
451        {
452            return natureEnseignements;
453        }
454        
455        Map<String, Pair<String, String>> natures = new HashMap<>();
456        natures.put("CM", Pair.of("Cours Magistral", "CM"));
457        natures.put("TD", Pair.of("Travaux Dirigés", "TD"));
458        natures.put("TP", Pair.of("Travaux Pratique", "TP"));
459        return natures;
460    }
461    
462    /**
463     * List of the course parts nature categories.
464     * @param natureEnseignementCategories the map of nature enseignement categories
465     * @return A {@link Map} with the code as a key, and the title as a value
466     */
467    protected Map<String, Pair<String, Long>> _getNaturesEnseignementCategoryList(Map<String, Pair<String, Long>> natureEnseignementCategories)
468    {
469        if (natureEnseignementCategories != null && !natureEnseignementCategories.isEmpty())
470        {
471            return natureEnseignementCategories;
472        }
473        
474        Map<String, Pair<String, Long>> categories = new HashMap<>();
475        categories.put("CM", Pair.of("Cours Magistral", 1L));
476        categories.put("TD", Pair.of("Travaux Dirigés", 2L));
477        categories.put("TP", Pair.of("Travaux Pratique", 3L));
478        return categories;
479    }
480    
481    /**
482     * {@link ContentWorkflowHelper} cannot be used in these conditions.
483     * @param content The content
484     * @param actionId Action to perform
485     * @return The result map
486     * @throws WorkflowException if an error occurs
487     * @throws AmetysRepositoryException if an error occurs
488     */
489    protected Map<String, Object> _doAction(WorkflowAwareContent content, Integer actionId) throws AmetysRepositoryException, WorkflowException
490    {
491        Map<String, Object> inputs = new HashMap<>();
492        Map<String, Object> results = new HashMap<>();
493        inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, results);
494        inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, content);
495        inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<String>());
496        inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, new HashMap<String, Object>());
497        
498        try
499        {
500            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(content);
501            workflow.doAction(content.getWorkflowId(), actionId, inputs);
502        }
503        catch (InvalidActionException e)
504        {
505            getLogger().error("An error occured while do workflow action '{}' on content '{}'", actionId, content.getId(), e);
506            throw e; 
507        }
508        
509        return results;
510    }
511    
512    /**
513     * Get or create the nature enseignement if it doesn't exist. The code is tested.
514     * @param title The title of the nature
515     * @param code The code of the nature
516     * @param order The order to set.
517     * @return The corresponding entry
518     * @throws AmetysRepositoryException if an error occurs
519     * @throws WorkflowException if an error occurs
520     */
521    protected OdfReferenceTableEntry _getOrCreateNatureEnseignement(String title, String code, Long order) throws AmetysRepositoryException, WorkflowException
522    {
523        OdfReferenceTableEntry categoryEntry = _odfRefTableHelper.getItemFromCode(OdfReferenceTableHelper.ENSEIGNEMENT_NATURE_CATEGORY, code);
524        
525        if (categoryEntry == null)
526        {
527            Map<String, Object> result = _workflowHelper.createContent("reference-table", 1, title, title, new String[] {OdfReferenceTableHelper.ENSEIGNEMENT_NATURE_CATEGORY}, new String[0], "fr");
528            ModifiableDefaultContent categoryContent = (ModifiableDefaultContent) result.get(AbstractContentWorkflowComponent.CONTENT_KEY);
529            categoryContent.setValue(OdfReferenceTableEntry.CODE, code);
530            categoryContent.setValue("order", order);
531            categoryContent.saveChanges();
532            _doAction(categoryContent, 22);
533            
534            categoryEntry = new OdfReferenceTableEntry(categoryContent);
535        }
536        
537        return categoryEntry;
538    }
539}