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.FilterNameHelper;
036import org.ametys.cms.content.external.ExternalizableMetadataHelper;
037import org.ametys.cms.data.ContentDataHelper;
038import org.ametys.cms.repository.ContentQueryHelper;
039import org.ametys.cms.repository.ContentTypeExpression;
040import org.ametys.cms.repository.ModifiableDefaultContent;
041import org.ametys.cms.repository.WorkflowAwareContent;
042import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
043import org.ametys.cms.workflow.ContentWorkflowHelper;
044import org.ametys.cms.workflow.CreateContentFunction;
045import org.ametys.odf.course.Course;
046import org.ametys.odf.course.CourseFactory;
047import org.ametys.odf.coursepart.CoursePart;
048import org.ametys.odf.coursepart.CoursePartFactory;
049import org.ametys.odf.enumeration.OdfReferenceTableEntry;
050import org.ametys.odf.enumeration.OdfReferenceTableHelper;
051import org.ametys.odf.workflow.ValidateODFContentFunction;
052import org.ametys.plugins.repository.AmetysObjectIterable;
053import org.ametys.plugins.repository.AmetysObjectResolver;
054import org.ametys.plugins.repository.AmetysRepositoryException;
055import org.ametys.plugins.repository.data.repositorydata.impl.JCRRepositoryData;
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(), ValidateODFContentFunction.VALID_LABEL);
134            boolean currentVersionIsLive = hasLiveVersion && ArrayUtils.contains(course.getLabels(), ValidateODFContentFunction.VALID_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(ValidateODFContentFunction.VALID_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            ExternalizableMetadataHelper.removeMetadataIfExists(course.getMetadataHolder(), Course.CHILD_COURSE_PARTS);
218        }
219        
220        if (course.needsSave())
221        {
222            course.saveChanges();
223            course.checkpoint();
224        }
225    }
226    
227    /**
228     * Create a course part linked to the course.
229     * @param course The {@link Course} to migrate
230     * @param natureByCode The course part natures
231     * @param isLive Set the Live label if <code>true</code>
232     * @return The created course parts
233     */
234    protected Map<String, CoursePart> _migrateCoursePart(Course course, Map<String, OdfReferenceTableEntry> natureByCode, boolean isLive)
235    {
236        JCRRepositoryData courseRepoData = new JCRRepositoryData(course.getNode());
237        
238        Map<String, CoursePart> createdCourseParts = new HashMap<>();
239        
240        // Create the course parts
241        for (String natureCode : natureByCode.keySet())
242        {
243            String dataName = "totalDurationOf" + natureCode;
244            double totalDurationOf = courseRepoData.hasValue(dataName) ? courseRepoData.getDouble(dataName) : 0;
245            
246            if (totalDurationOf > 0)
247            {
248                Optional.ofNullable(_createCoursePart(course, natureByCode.get(natureCode), totalDurationOf, isLive))
249                    .ifPresent(coursePart -> createdCourseParts.put(natureCode, coursePart));
250            }
251
252            removeExternalizableData(courseRepoData, dataName);
253        }
254        
255        // Add the course part to the course
256        if (!createdCourseParts.isEmpty())
257        {
258            Set<String> coursePartIds = createdCourseParts.values()
259                .stream()
260                .map(CoursePart::getId)
261                .collect(Collectors.toSet());
262            
263            coursePartIds.addAll(ContentDataHelper.getContentIdsListFromMultipleContentData(course, Course.CHILD_COURSE_PARTS));
264            course.setValue(Course.CHILD_COURSE_PARTS, coursePartIds.toArray(new String[coursePartIds.size()]));
265        }
266        
267        if (course.needsSave())
268        {
269            // Versions précédentes incompatibles
270            course.addLabel("NotCompatible", true);
271            
272            // Sauvegarde et avancement
273            course.saveChanges();
274            course.checkpoint();
275        }
276        
277        if (isLive)
278        {
279            course.addLabel(ValidateODFContentFunction.VALID_LABEL, true);
280        }
281        
282        return createdCourseParts;
283    }
284    
285    /**
286     * Remove a data and its externalizable data
287     * @param repositoryData the repository data containing the data
288     * @param dataName name of the data to remove
289     */
290    protected void removeExternalizableData(JCRRepositoryData repositoryData, String dataName)
291    {
292        if (repositoryData.hasValue(dataName))
293        {
294            repositoryData.removeValue(dataName);
295        }
296        
297        if (repositoryData.hasValue(dataName + ExternalizableMetadataHelper.ALTERNATIVE_SUFFIX))
298        {
299            repositoryData.removeValue(dataName + ExternalizableMetadataHelper.ALTERNATIVE_SUFFIX);
300        }
301        
302        if (repositoryData.hasValue(dataName + ExternalizableMetadataHelper.STATUS_SUFFIX))
303        {
304            repositoryData.removeValue(dataName + ExternalizableMetadataHelper.STATUS_SUFFIX);
305        }
306    }
307    
308    /**
309     * Create a course part linked to the course.
310     * @param course The {@link Course} holder
311     * @param nature The nature of the course part
312     * @param totalDurationOf The number of hours
313     * @param isLive Set the Live label if <code>true</code>
314     * @return The {@link CoursePart} id
315     */
316    protected CoursePart _createCoursePart(Course course, OdfReferenceTableEntry nature, Double totalDurationOf, boolean isLive)
317    {
318     // Create the course part
319        String coursePartTitle = course.getTitle();
320        if (nature != null)
321        {
322            coursePartTitle += " - " + nature.getCode();
323        }
324        String coursePartName = FilterNameHelper.filterName(coursePartTitle);
325        
326        Map<String, Object> resultMap = new HashMap<>();
327        
328        Map<String, Object> inputs = new HashMap<>();
329        inputs.put(CreateContentFunction.CONTENT_LANGUAGE_KEY, course.getLanguage());
330        inputs.put(CreateContentFunction.CONTENT_NAME_KEY, coursePartName); 
331        inputs.put(CreateContentFunction.CONTENT_TITLE_KEY, coursePartTitle);
332        inputs.put(CreateContentFunction.CONTENT_TYPES_KEY, new String[] {CoursePartFactory.COURSE_PART_CONTENT_TYPE});
333        
334        Map<String, Object> results = new HashMap<>();
335        inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, results);
336        
337        CoursePart coursePart = null;
338        try
339        {
340            Map<String, Object> workflowResult = _workflowHelper.createContent("course-part", 1, coursePartName, coursePartTitle, new String[] {CoursePartFactory.COURSE_PART_CONTENT_TYPE}, null, course.getLanguage());
341            coursePart = (CoursePart) workflowResult.get(AbstractContentWorkflowComponent.CONTENT_KEY);
342            
343            if (nature != null)
344            {
345                coursePart.setValue(CoursePart.NATURE, nature.getId());
346            }
347            coursePart.setValue(CoursePart.NB_HOURS, totalDurationOf);
348            coursePart.setValue(CoursePart.COURSE_HOLDER, course.getId());
349            coursePart.setValue(CoursePart.PARENT_COURSES, new String[] {course.getId()});
350            coursePart.setValue(CoursePart.CATALOG, course.getCatalog());
351            
352            _setAdditionalValues(coursePart, course);
353            
354            coursePart.saveChanges();
355            coursePart.checkpoint();
356            
357            if (isLive)
358            {
359                coursePart.addLabel(ValidateODFContentFunction.VALID_LABEL, true);
360            }
361        }
362        catch (WorkflowException e)
363        {
364            resultMap.put("error", Boolean.TRUE);
365            getLogger().error("Failed to initialize workflow for content '{}' and language '{}'", coursePartTitle, course.getLanguage(), e);
366        }
367        
368        return coursePart;
369    }
370    
371    /**
372     * Set additional values to the {@link CoursePart} from the {@link Course}.
373     * @param coursePart The course part to modify
374     * @param course The original course
375     */
376    protected void _setAdditionalValues(CoursePart coursePart, Course course)
377    {
378        // Nothing to do
379    }
380    
381    /**
382     * Create entries into the reference table EnseignementNature if they don't exist.
383     * @param natureEnseignementCategories the map of nature enseignement categories
384     * @param natureEnseignements the map of nature enseignement
385     * @return The list of natures with their code and associated ID.
386     * @throws AmetysRepositoryException if an error occurs
387     * @throws WorkflowException if an error occurs
388     */
389    protected Map<String, OdfReferenceTableEntry> _createNaturesEnseignement(Map<String, Pair<String, Long>> natureEnseignementCategories, Map<String, Pair<String, String>> natureEnseignements) throws AmetysRepositoryException, WorkflowException
390    {
391        Map<String, String> categoryByCode = _createNatureEnseignementCategories(natureEnseignementCategories);
392        Map<String, OdfReferenceTableEntry> natureByCode = new HashMap<>();
393        
394        for (Map.Entry<String, Pair<String, String>> nature : _getNaturesEnseignementList(natureEnseignements).entrySet())
395        {
396            String natureCode = nature.getKey();
397            OdfReferenceTableEntry natureEntry = _odfRefTableHelper.getItemFromCode(OdfReferenceTableHelper.ENSEIGNEMENT_NATURE, natureCode);
398            
399            if (natureEntry == null)
400            {
401                String title = nature.getValue().getLeft();
402                String categoryCode = nature.getValue().getRight();
403                Map<String, String> titleVariants = new HashMap<>();
404                titleVariants.put("fr", title);
405                Map<String, Object> result = _workflowHelper.createContent("reference-table", 1, title, titleVariants, new String[] {OdfReferenceTableHelper.ENSEIGNEMENT_NATURE}, new String[0]);
406                ModifiableDefaultContent natureContent = (ModifiableDefaultContent) result.get(AbstractContentWorkflowComponent.CONTENT_KEY);
407                natureContent.setValue(OdfReferenceTableEntry.CODE, natureCode);
408                natureContent.setValue("category", categoryByCode.get(categoryCode));
409                natureContent.saveChanges();
410                _doAction(natureContent, 22);
411                
412                natureEntry = new OdfReferenceTableEntry(natureContent);
413            }
414            
415            natureByCode.put(natureCode, natureEntry);
416        }
417        
418        return natureByCode;
419    }
420
421    /**
422     * Create entries into the reference table EnseignementNature if they don't exist.
423     * @param natureEnseignementCategories the map of nature enseignement categories
424     * @return The list of natures with their code and associated ID.
425     * @throws AmetysRepositoryException if an error occurs
426     * @throws WorkflowException if an error occurs
427     */
428    protected Map<String, String> _createNatureEnseignementCategories(Map<String, Pair<String, Long>> natureEnseignementCategories) throws AmetysRepositoryException, WorkflowException
429    {
430        Map<String, String> categoryByCode = new HashMap<>();
431        
432        for (Map.Entry<String, Pair<String, Long>> category : _getNaturesEnseignementCategoryList(natureEnseignementCategories).entrySet())
433        {
434            String categoryCode = category.getKey();
435            OdfReferenceTableEntry categoryEntry = _getOrCreateNatureEnseignement(category.getValue().getLeft(), categoryCode, category.getValue().getRight());
436            categoryByCode.put(categoryCode, categoryEntry.getId());
437        }
438        
439        return categoryByCode;
440    }
441    
442    /**
443     * List of the course parts natures.
444     * @param natureEnseignements the map of nature enseignement
445     * @return A {@link Map} with the code as a key, and a {@link Pair} with the title and the category code as a value
446     */
447    protected Map<String, Pair<String, String>> _getNaturesEnseignementList(Map<String, Pair<String, String>> natureEnseignements)
448    {
449        if (natureEnseignements != null && !natureEnseignements.isEmpty())
450        {
451            return natureEnseignements;
452        }
453        
454        Map<String, Pair<String, String>> natures = new HashMap<>();
455        natures.put("CM", Pair.of("Cours Magistral", "CM"));
456        natures.put("TD", Pair.of("Travaux Dirigés", "TD"));
457        natures.put("TP", Pair.of("Travaux Pratique", "TP"));
458        return natures;
459    }
460    
461    /**
462     * List of the course parts nature categories.
463     * @param natureEnseignementCategories the map of nature enseignement categories
464     * @return A {@link Map} with the code as a key, and the title as a value
465     */
466    protected Map<String, Pair<String, Long>> _getNaturesEnseignementCategoryList(Map<String, Pair<String, Long>> natureEnseignementCategories)
467    {
468        if (natureEnseignementCategories != null && !natureEnseignementCategories.isEmpty())
469        {
470            return natureEnseignementCategories;
471        }
472        
473        Map<String, Pair<String, Long>> categories = new HashMap<>();
474        categories.put("CM", Pair.of("Cours Magistral", 1L));
475        categories.put("TD", Pair.of("Travaux Dirigés", 2L));
476        categories.put("TP", Pair.of("Travaux Pratique", 3L));
477        return categories;
478    }
479    
480    /**
481     * {@link ContentWorkflowHelper} cannot be used in these conditions.
482     * @param content The content
483     * @param actionId Action to perform
484     * @return The result map
485     * @throws WorkflowException if an error occurs
486     * @throws AmetysRepositoryException if an error occurs
487     */
488    protected Map<String, Object> _doAction(WorkflowAwareContent content, Integer actionId) throws AmetysRepositoryException, WorkflowException
489    {
490        Map<String, Object> inputs = new HashMap<>();
491        Map<String, Object> results = new HashMap<>();
492        inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, results);
493        inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, content);
494        inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<String>());
495        inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, new HashMap<String, Object>());
496        
497        try
498        {
499            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(content);
500            workflow.doAction(content.getWorkflowId(), actionId, inputs);
501        }
502        catch (InvalidActionException e)
503        {
504            getLogger().error("An error occured while do workflow action '{}' on content '{}'", actionId, content.getId(), e);
505            throw e; 
506        }
507        
508        return results;
509    }
510    
511    /**
512     * Get or create the nature enseignement if it doesn't exist. The code is tested.
513     * @param title The title of the nature
514     * @param code The code of the nature
515     * @param order The order to set.
516     * @return The corresponding entry
517     * @throws AmetysRepositoryException if an error occurs
518     * @throws WorkflowException if an error occurs
519     */
520    protected OdfReferenceTableEntry _getOrCreateNatureEnseignement(String title, String code, Long order) throws AmetysRepositoryException, WorkflowException
521    {
522        OdfReferenceTableEntry categoryEntry = _odfRefTableHelper.getItemFromCode(OdfReferenceTableHelper.ENSEIGNEMENT_NATURE_CATEGORY, code);
523        
524        if (categoryEntry == null)
525        {
526            Map<String, Object> result = _workflowHelper.createContent("reference-table", 1, title, title, new String[] {OdfReferenceTableHelper.ENSEIGNEMENT_NATURE_CATEGORY}, new String[0], "fr");
527            ModifiableDefaultContent categoryContent = (ModifiableDefaultContent) result.get(AbstractContentWorkflowComponent.CONTENT_KEY);
528            categoryContent.setValue(OdfReferenceTableEntry.CODE, code);
529            categoryContent.setValue("order", order);
530            categoryContent.saveChanges();
531            _doAction(categoryContent, 22);
532            
533            categoryEntry = new OdfReferenceTableEntry(categoryContent);
534        }
535        
536        return categoryEntry;
537    }
538}