001/*
002 *  Copyright 2025 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.util.ArrayList;
019import java.util.Arrays;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Objects;
024import java.util.Optional;
025import java.util.stream.Stream;
026
027import org.apache.avalon.framework.service.ServiceException;
028import org.apache.avalon.framework.service.ServiceManager;
029import org.apache.avalon.framework.service.Serviceable;
030
031import org.ametys.cms.ObservationConstants;
032import org.ametys.cms.data.ContentValue;
033import org.ametys.cms.repository.Content;
034import org.ametys.cms.repository.ContentQueryHelper;
035import org.ametys.cms.repository.ContentTypeExpression;
036import org.ametys.cms.repository.DefaultContent;
037import org.ametys.cms.repository.ModifiableContent;
038import org.ametys.cms.repository.ModifiableDefaultContent;
039import org.ametys.cms.repository.WorkflowAwareContent;
040import org.ametys.cms.workflow.ContentWorkflowHelper;
041import org.ametys.cms.workflow.EditContentFunction;
042import org.ametys.core.observation.Event;
043import org.ametys.core.observation.ObservationManager;
044import org.ametys.core.user.CurrentUserProvider;
045import org.ametys.odf.ODFHelper;
046import org.ametys.odf.course.Course;
047import org.ametys.odf.program.Program;
048import org.ametys.odf.program.ProgramFactory;
049import org.ametys.odf.skill.ODFSkillsHelper;
050import org.ametys.odf.skill.workflow.SkillEditionFunction;
051import org.ametys.plugins.repository.AmetysObjectResolver;
052import org.ametys.plugins.repository.AmetysRepositoryException;
053import org.ametys.plugins.repository.ModifiableAmetysObject;
054import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
055import org.ametys.plugins.repository.RemovableAmetysObject;
056import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeater;
057import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeaterEntry;
058import org.ametys.plugins.repository.jcr.NameHelper;
059import org.ametys.plugins.repository.lock.LockableAmetysObject;
060import org.ametys.plugins.repository.query.expression.AndExpression;
061import org.ametys.plugins.repository.query.expression.Expression;
062import org.ametys.plugins.repository.query.expression.Expression.Operator;
063import org.ametys.plugins.repository.query.expression.StringExpression;
064import org.ametys.plugins.workflow.AbstractWorkflowComponent;
065import org.ametys.plugins.workflow.component.CheckRightsCondition;
066import org.ametys.runtime.plugin.component.AbstractLogEnabled;
067
068import com.opensymphony.workflow.WorkflowException;
069
070/**
071 * Copy updater to update the micro skills on a macro skill and the macro skills on a {@link Program} and (@link Course}.
072 */
073public class SkillsCopyUpdater extends AbstractLogEnabled implements CopyCatalogUpdater, Serviceable
074{
075    private static final List<String> __SKILLS_IGNORED_ATTRIBUTES = List.of("parentMacroSkill", "microSkills", "parentProgram", "catalog");
076    
077    /** The ametys object resolver */
078    protected AmetysObjectResolver _resolver;
079    /** The observation manager */
080    protected ObservationManager _observationManager;
081    /** The current user provider */
082    protected CurrentUserProvider _currentUserProvider;
083    /** The content workflow helper */
084    protected ContentWorkflowHelper _contentWorkflowHelper;
085    /** The ODF helper */
086    protected ODFHelper _odfHelper;
087    
088    public void service(ServiceManager manager) throws ServiceException
089    {
090        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
091        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
092        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
093        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
094        _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE);
095    }
096    
097    public void updateContents(String initialCatalogName, String newCatalogName, Map<Content, Content> copiedContents, Content targetParentContent)
098    {
099        // Do Nothing
100    }
101    
102    public List<Content> getAdditionalContents(String catalogName)
103    {
104        List<Content> results = new ArrayList<>();
105        
106        results.addAll(_getContents(catalogName, SkillEditionFunction.MICRO_SKILL_TYPE));
107        results.addAll(_getContents(catalogName, SkillEditionFunction.MACRO_SKILL_TYPE));
108        
109        return results;
110    }
111    
112    public void copyAdditionalContents(String initialCatalogName, String newCatalogName, Map<Content, Content> copiedContents)
113    {
114        // Only copy the skills if they are enabled
115        if (!ODFSkillsHelper.isSkillsEnabled())
116        {
117            return;
118        }
119        
120        // Get the skills of the catalog to copy
121        List<DefaultContent> microSkillsToCopy = _getContents(initialCatalogName, SkillEditionFunction.MICRO_SKILL_TYPE);
122        List<DefaultContent> macroSkillsToCopy = _getContents(initialCatalogName, SkillEditionFunction.MACRO_SKILL_TYPE);
123        
124        // Copy the micro skills in the new catalog
125        Map<String, Content> copiedMicroSkills = _copyMicroSkills(microSkillsToCopy, newCatalogName);
126        // Copy the macro skills in the new catalog and update the links to the copied micro skills
127        Map<String, Content> copiedMacroSkills = _copyMacroSkills(macroSkillsToCopy, newCatalogName, copiedMicroSkills);
128
129        // Update the links to the copied macro skills in programs and skills affectations in courses
130        _updateContentsAfterSkillsCreation(newCatalogName, copiedContents, copiedMacroSkills, copiedMicroSkills);
131    }
132    
133    private Map<String, Content> _copyMacroSkills(List<DefaultContent> skills, String newCatalogName, Map<String, Content> copiedMicroSkills)
134    {
135        return _copySkills(skills, SkillEditionFunction.MACRO_SKILL_TYPE, newCatalogName, copiedMicroSkills);
136    }
137    
138    private Map<String, Content> _copyMicroSkills(List<DefaultContent> skills, String newCatalogName)
139    {
140        return _copySkills(skills, SkillEditionFunction.MICRO_SKILL_TYPE, newCatalogName, Map.of());
141    }
142    
143    private Map<String, Content> _copySkills(List<DefaultContent> skills, String contentType, String newCatalogName, Map<String, Content> copiedMicroSkills)
144    {
145        Map<String, Content> copiedSkills = new HashMap<>();
146        for (DefaultContent skill : skills)
147        {
148            // Log if the targeted catalog already contains the skill
149            if (_skillExists(skill, contentType, newCatalogName))
150            {
151                getLogger().info("A skill already exists with the same code, catalog and language [{}, {}, {}]", skill.getValue("code"), newCatalogName, skill.getLanguage());
152            }
153            // Copy the skill in the targeted catalog
154            else
155            {
156                try
157                {
158                    ModifiableContent newSkill = _createSkill(skill, newCatalogName);
159                    
160                    // If the skill is a macroSkill and has microSkills values, update the linkes to point to the new ones
161                    if (contentType.equals(SkillEditionFunction.MACRO_SKILL_TYPE) && skill.hasValue("microSkills"))
162                    {
163                        // If the content is a macroSkill and has microSkills, link the new ones that where previously copied
164                        Map<String, Object> values = new HashMap<>();
165                        ContentValue[] previousMicroSkills = skill.getValue("microSkills");
166                        List<Content> microSkills = Arrays.asList(previousMicroSkills)
167                                .stream()
168                                .filter(Objects::nonNull)
169                                .map(microSkill -> copiedMicroSkills.get(microSkill.getContentId()))
170                                .toList();
171                        
172                        values.put("microSkills", microSkills);
173                        try
174                        {
175                            _contentWorkflowHelper.editContent((ModifiableDefaultContent) newSkill, values, 2);
176                        }
177                        catch (Exception e)
178                        {
179                            // Log and rollback
180                            getLogger().error("Impossible to update skill '{}' ({}) while creating the catalog {}", newSkill.getTitle(), newSkill.getId(), newCatalogName);
181                            
182                            _deleteContent(newSkill);
183                        }
184                    }
185                    
186                    // If the skill could be created, add it to the copied skills
187                    if (newSkill != null)
188                    {
189                        copiedSkills.put(skill.getId(), newSkill);
190                    }
191                }
192                catch (AmetysRepositoryException e)
193                {
194                    getLogger().error("Impossible to create the skill '{}' ({}) while creating the catalog {}", skill.getTitle(), skill.getId(), newCatalogName, e);
195                }
196            }
197        }
198        
199        return copiedSkills;
200    }
201    
202    private ModifiableContent _createSkill(DefaultContent skill, String newCatalogName) throws AmetysRepositoryException
203    {
204        // Create the skill in the new catalog
205        ModifiableContent newSkill = skill.copyTo((ModifiableTraversableAmetysObject) skill.getParent(), NameHelper.filterName(skill.getTitle()));
206        
207        // Remove the attributes that need to be updated
208        for (String data : newSkill.getDataNames())
209        {
210            // Remove the attribute that can't be copied
211            if (__SKILLS_IGNORED_ATTRIBUTES.contains(data))
212            {
213                newSkill.removeValue(data);
214            }
215        }
216        // Set the new catalog
217        newSkill.setValue("catalog", newCatalogName);
218        newSkill.saveChanges();
219        
220        return newSkill;
221    }
222    
223    private void _updateContentsAfterSkillsCreation(String newCatalogName, Map<Content, Content> copiedContents, Map<String, Content> copiedMacroSkills, Map<String, Content> copiedMicroSkills)
224    {
225        // For every copied program, update its links from the original macro skills to the copied macro skills
226        for (Content copiedContent : copiedContents.values())
227        {
228            try
229            {
230                if (copiedContent instanceof Program program)
231                {
232                    _updateProgram(program, copiedMacroSkills, copiedMicroSkills);
233                }
234                else if (copiedContent instanceof Course course)
235                {
236                    _updateCourse(course, copiedMicroSkills);
237                }
238            }
239            catch (Exception e)
240            {
241                getLogger().error("An error occurred while copying the program '{}' in the new catalog '{}'", copiedContent.getId(), newCatalogName, e);
242            }
243        }
244    }
245    
246    private void _updateProgram(Program program, Map<String, Content> copiedMacroSkills, Map<String, Content> copiedMicroSkills) throws WorkflowException
247    {
248        ContentValue[] programSkills = program.getValue(Program.SKILLS);
249        // Remove the skills without triggering the observer that would delete the skills
250        program.removeValue(Program.SKILLS);
251        program.saveChanges();
252
253        // If the program has skills, update the links to target the copied macro skills
254        if (programSkills != null)
255        {
256            Content[] skills = _getCopiedSkills(programSkills, copiedMacroSkills);
257            
258            for (Content skill : skills)
259            {
260                ModifiableContent skillModifiable = (ModifiableContent) skill;
261                // Set the parent program of the copied skills
262                skillModifiable.synchronizeValues(Map.of("parentProgram", program.getId()));
263                skillModifiable.saveChanges();
264                
265                // To update the version and indexation
266                _editContent(skillModifiable);
267            }
268            
269            program.saveChanges();
270        }
271        
272        ContentValue[] programTransversalSkills = program.getValue(Program.TRANSVERSAL_SKILLS);
273        // If the program has transversal skills linked, update the link to target the copied macro skills
274        if (programTransversalSkills != null)
275        {
276            Content[] transversalSkills = _getCopiedSkills(programTransversalSkills, copiedMacroSkills);
277            program.setValue(Program.TRANSVERSAL_SKILLS, transversalSkills);
278            program.saveChanges();
279        }
280        
281        ContentValue[] programBlockingMicroSkills = program.getValue(Program.BLOCKING_SKILLS);
282        // If the program has blocking skills linked, update the link to target the copied micro skills
283        if (programBlockingMicroSkills != null)
284        {
285            Content[] blockingMicroSkills = _getCopiedSkills(programBlockingMicroSkills, copiedMicroSkills);
286            program.setValue(Program.BLOCKING_SKILLS, blockingMicroSkills);
287            program.saveChanges();
288        }
289    }
290    
291    private void _updateCourse(Course course, Map<String, Content> copiedMicroSkills)
292    {
293        String catalog = course.getCatalog();
294        
295        // Get the micro skills of the course by program
296        List<? extends ModifiableModelAwareRepeaterEntry> microSkillsByProgramEntries = Optional.ofNullable(course.getRepeater(Course.ACQUIRED_MICRO_SKILLS))
297                .map(ModifiableModelAwareRepeater::getEntries)
298                .orElse(List.of());
299        
300        for (ModifiableModelAwareRepeaterEntry entry : microSkillsByProgramEntries)
301        {
302            ContentValue[] microSkills = entry.getValue(Course.ACQUIRED_MICRO_SKILLS_SKILLS, false, new ContentValue[0]);
303            Content[] newMicroSkills = _getCopiedSkills(microSkills, copiedMicroSkills);
304            entry.setValue(Course.ACQUIRED_MICRO_SKILLS_SKILLS, newMicroSkills);
305            
306            String programId = Optional.ofNullable(entry.<ContentValue>getValue(Course.ACQUIRED_MICRO_SKILLS_PROGRAM))
307                    .flatMap(ContentValue::getContentIfExists)
308                    .map(Program.class::cast)
309                    .map(p -> _getCopiedProgram(p, catalog))
310                    .map(Content::getId)
311                    .orElse(null);
312            entry.setValue(Course.ACQUIRED_MICRO_SKILLS_PROGRAM, programId);
313        }
314        course.saveChanges();
315    }
316    
317    private Program _getCopiedProgram(Program program, String newCatalog)
318    {
319        return _odfHelper.getODFContent(ProgramFactory.PROGRAM_CONTENT_TYPE, program.getCode(), newCatalog, program.getLanguage());
320    }
321    
322    private Content[] _getCopiedSkills(ContentValue[] originalSkills, Map<String, Content> copiedSkills)
323    {
324        return Arrays.asList(originalSkills)
325                     .stream()
326                     // Keep the former skill if the copy is not found
327                     .map(originalSkill -> copiedSkills.getOrDefault(originalSkill.getContentId(), originalSkill.getContent()))
328                     .toArray(Content[]::new);
329    }
330    
331    private boolean _skillExists(Content skill, String contentType, String newCatalogName)
332    {
333        return _getContents(newCatalogName, contentType, skill).findAny().isPresent();
334    }
335    
336    private <T extends Content> List<T> _getContents(String catalogName, String contentType)
337    {
338        return this.<T>_getContents(catalogName, contentType, null).toList();
339    }
340    
341    private <T extends Content> Stream<T> _getContents(String catalogName, String contentType, Content skill)
342    {
343        List<Expression> exprs = new ArrayList<>();
344        exprs.add(new ContentTypeExpression(Operator.EQ, contentType));
345        exprs.add(new StringExpression("catalog", Operator.EQ, catalogName));
346        if (skill != null)
347        {
348            exprs.add(new StringExpression("code", Operator.EQ, skill.getValue("code")));
349        }
350        Expression expression = new AndExpression(exprs.toArray(Expression[]::new));
351        
352        String query = ContentQueryHelper.getContentXPathQuery(expression);
353        return _resolver.<T>query(query).stream();
354    }
355    
356    private void _deleteContent(Content content)
357    {
358        Map<String, Object> eventParams = new HashMap<>();
359        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
360        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
361        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
362        ModifiableAmetysObject parent = content.getParent();
363        
364        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParams));
365        
366        // Remove the content.
367        LockableAmetysObject lockedContent = (LockableAmetysObject) content;
368        if (lockedContent.isLocked())
369        {
370            lockedContent.unlock();
371        }
372        
373        ((RemovableAmetysObject) content).remove();
374        
375        parent.saveChanges();
376        
377        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams));
378    }
379    
380    private void _editContent(Content content) throws AmetysRepositoryException, WorkflowException
381    {
382        Map<String, Object> parameters = new HashMap<>();
383        parameters.put(EditContentFunction.QUIT, true);
384
385        Map<String, Object> inputs = new HashMap<>();
386        inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, parameters);
387        // Force because we don't want to control user rights with this operation
388        inputs.put(CheckRightsCondition.FORCE, true);
389        
390        _contentWorkflowHelper.doAction((WorkflowAwareContent) content, 222, inputs);
391    }
392}