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.skill;
017
018import java.util.ArrayList;
019import java.util.Arrays;
020import java.util.HashMap;
021import java.util.HashSet;
022import java.util.LinkedHashMap;
023import java.util.LinkedHashSet;
024import java.util.List;
025import java.util.Map;
026import java.util.Objects;
027import java.util.Optional;
028import java.util.Set;
029import java.util.function.Predicate;
030import java.util.stream.Collectors;
031import java.util.stream.Stream;
032
033import org.apache.avalon.framework.component.Component;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037
038import org.ametys.cms.ObservationConstants;
039import org.ametys.cms.contenttype.ContentTypesHelper;
040import org.ametys.cms.data.ContentValue;
041import org.ametys.cms.indexing.solr.SolrIndexHelper;
042import org.ametys.cms.repository.Content;
043import org.ametys.cms.repository.ContentDAO;
044import org.ametys.cms.repository.ContentQueryHelper;
045import org.ametys.cms.repository.ContentTypeExpression;
046import org.ametys.core.observation.Event;
047import org.ametys.core.observation.ObservationManager;
048import org.ametys.core.right.RightManager;
049import org.ametys.core.right.RightManager.RightResult;
050import org.ametys.core.ui.Callable;
051import org.ametys.core.user.CurrentUserProvider;
052import org.ametys.odf.ODFHelper;
053import org.ametys.odf.ProgramItem;
054import org.ametys.odf.course.Course;
055import org.ametys.odf.course.CourseContainer;
056import org.ametys.odf.program.AbstractProgram;
057import org.ametys.odf.program.Container;
058import org.ametys.odf.program.Program;
059import org.ametys.odf.program.SubProgram;
060import org.ametys.odf.skill.workflow.SkillEditionFunction;
061import org.ametys.plugins.repository.AmetysObjectIterable;
062import org.ametys.plugins.repository.AmetysObjectResolver;
063import org.ametys.plugins.repository.ModifiableAmetysObject;
064import org.ametys.plugins.repository.UnknownAmetysObjectException;
065import org.ametys.plugins.repository.data.holder.group.ModifiableRepeater;
066import org.ametys.plugins.repository.data.holder.group.ModifiableRepeaterEntry;
067import org.ametys.plugins.repository.query.expression.AndExpression;
068import org.ametys.plugins.repository.query.expression.Expression;
069import org.ametys.plugins.repository.query.expression.Expression.Operator;
070import org.ametys.plugins.repository.query.expression.StringExpression;
071import org.ametys.runtime.config.Config;
072import org.ametys.runtime.plugin.component.AbstractLogEnabled;
073
074/**
075 * ODF skills helper
076 */
077public class ODFSkillsHelper extends AbstractLogEnabled implements Serviceable, Component
078{
079    /** The avalon role. */
080    public static final String ROLE = ODFSkillsHelper.class.getName();
081    
082    /** The internal attribute name to excluded from skills */
083    public static final String SKILLS_EXCLUDED_INTERNAL_ATTRIBUTE_NAME = "excluded";
084    
085    /** The right to edit, create, delete or import transversal skills */
086    public static final String RIGHT_TRANSVERSAL_SKILLS_HANDLE = "ODF_Rights_Transversal_Skills_Handle";
087    /** The right to edit, create or import disciplinary skills */
088    public static final String RIGHT_DISCIPLINARY_SKILLS_HANDLE = "ODF_Rights_Disciplinary_Skills_Handle";
089    /** The right to add skills to programs */
090    public static final String RIGHT_SKILLS_PROGRAM_FIELDS = "ODF_Rights_Skills_Program_Fields";
091    
092    /** The right to export all skills */
093    public static final String RIGHT_SKILLS_EXPORT = "ODF_Rights_Skills_Export";
094    
095    /** The ametys object resolver */
096    protected AmetysObjectResolver _resolver;
097    
098    /** The content types helper */
099    protected ContentTypesHelper _contentTypesHelper;
100    
101    /** The content dao */
102    protected ContentDAO _contentDao;
103
104    /** The ODF helper */
105    protected ODFHelper _odfHelper;
106    
107    /** The observation manager */
108    protected ObservationManager _observationManager;
109
110    /** The Solr index helper */
111    protected SolrIndexHelper _solrIndexHelper;
112    
113    /** The current user provider */
114    protected CurrentUserProvider _currentUserProvider;
115
116    /** The right manager */
117    protected RightManager _rightManager;
118    
119    public void service(ServiceManager manager) throws ServiceException
120    {
121        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
122        _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
123        _contentDao = (ContentDAO) manager.lookup(ContentDAO.ROLE);
124        _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE);
125        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
126        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
127        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
128    }
129    
130    /**
131     * Determines if rules are enabled
132     * @return <code>true</code> if rules are enabled
133     */
134    public static boolean isSkillsEnabled()
135    {
136        return Config.getInstance().getValue("odf.skills.enabled", false, false);
137    }
138    
139    /**
140     * Get the path of the ODF root content
141     * @return The path of the ODF root content
142     */
143    @Callable (rights = Callable.NO_CHECK_REQUIRED)
144    public String getOdfRootContentPath()
145    {
146        return _odfHelper.getRootContent(false).getPath();
147    }
148    
149    /**
150     * Trash transversal macro skills
151     * @param contentIds The macro skill ids
152     * @return the deleted and undeleted contents
153     */
154    @Callable (rights = RIGHT_TRANSVERSAL_SKILLS_HANDLE)
155    public Map<String, Object> forceTrashTransversalMacroSkills(List<String> contentIds)
156    {
157        List<Content> contents = contentIds.stream()
158                .map(_resolver::<Content>resolveById)
159                .toList();
160        
161        if (contents.stream().anyMatch(content -> !_contentTypesHelper.isInstanceOf(content, SkillEditionFunction.TRANSVERSAL_MACRO_SKILL_TYPE)))
162        {
163            throw new IllegalArgumentException("Tried to delete transversal macro skills without sufficient right");
164        }
165        
166        // Call the content dao with null right because we already checked the right
167        return _contentDao.forceTrashContentsObj(
168            contents,
169            null
170        );
171    }
172    
173    /**
174     * Exclude or include the program items from skills display
175     * @param programItemIds the list of program item ids
176     * @param excluded <code>true</code> if the program items need to be excluded.
177     * @return the map of changed program items properties
178     */
179    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
180    public Map<String, Object> setProgramItemsExclusion(List<String> programItemIds, boolean excluded)
181    {
182        Map<String, Object> results = new HashMap<>();
183        results.put("allright-program-items", new ArrayList<>());
184        results.put("noright-program-items", new ArrayList<>());
185        
186        for (String programItemId : programItemIds)
187        {
188            ProgramItem programItem = _resolver.resolveById(programItemId);
189            if (programItem instanceof AbstractProgram || programItem instanceof Container)
190            {
191                Map<String, Object> programItem2Json = new HashMap<>();
192                programItem2Json.put("id", programItem.getId());
193                programItem2Json.put("title", ((Content) programItem).getTitle());
194                
195                if (_rightManager.hasRight(_currentUserProvider.getUser(), "ODF_Right_Skills_Excluded", programItem) == RightResult.RIGHT_ALLOW)
196                {
197                    ((Content) programItem).getInternalDataHolder().setValue(SKILLS_EXCLUDED_INTERNAL_ATTRIBUTE_NAME, excluded);
198                    ((ModifiableAmetysObject) programItem).saveChanges();
199                    
200                    @SuppressWarnings("unchecked")
201                    List<Map<String, Object>> allRightProgramItems = (List<Map<String, Object>>) results.get("allright-program-items");
202                    allRightProgramItems.add(programItem2Json);
203                    
204                    Map<String, Object> eventParams = new HashMap<>();
205                    eventParams.put(ObservationConstants.ARGS_CONTENT, programItem);
206                    eventParams.put(ObservationConstants.ARGS_CONTENT_ID, programItem.getId());
207                    eventParams.put(org.ametys.odf.observation.OdfObservationConstants.ODF_CONTENT_SKILLS_EXCLUSION_ARG, excluded);
208                    _observationManager.notify(new Event(org.ametys.odf.observation.OdfObservationConstants.ODF_CONTENT_SKILLS_EXCLUSION_CHANGED, _currentUserProvider.getUser(), eventParams));
209                }
210                else
211                {
212                    @SuppressWarnings("unchecked")
213                    List<Map<String, Object>> noRightProgramItems = (List<Map<String, Object>>) results.get("noright-program-items");
214                    noRightProgramItems.add(programItem2Json);
215                }
216            }
217        }
218        
219        return results;
220        
221    }
222    
223    /**
224     * <code>true</code> if the program item is excluded from skills display
225     * @param programItem the program item
226     * @return <code>true</code> if the program item is excluded from skills display
227     */
228    public boolean isExcluded(ProgramItem programItem)
229    {
230        // If the skills are not enabled, every item is excluded
231        if (!isSkillsEnabled())
232        {
233            return true;
234        }
235        
236        if (programItem instanceof AbstractProgram || programItem instanceof Container)
237        {
238            return ((Content) programItem).getInternalDataHolder().getValueOrDefault(SKILLS_EXCLUDED_INTERNAL_ATTRIBUTE_NAME, false);
239        }
240        
241        return false;
242    }
243    
244    /**
245     * Get the skills distribution by courses over a {@link AbstractProgram}
246     * Distribution is computed over the course of first level only
247     * @param abstractProgram The program or subProgram for which to get the skills distribution
248     * @return the skills distribution or null if the content is not a program or a compatible subProgram
249     */
250    public Map<Content, Map<Content, Set<Content>>> getSkillsDistribution(AbstractProgram abstractProgram)
251    {
252        return getSkillsDistribution(abstractProgram, 1);
253    }
254    
255    /**
256     * Get the skills distribution by courses over a {@link AbstractProgram}
257     * Distribution is computed over the course of first level only
258     * @param abstractProgram The program or subProgram for which to get the skills distribution
259     * @param maxDepth the max depth of courses. For example, set to 1 to compute distribution over UE only, set 2 to compute distribution over UE and EC, ...
260     * @return the skills distribution or null if the content is not a program or a compatible subProgram
261     */
262    public Map<Content, Map<Content, Set<Content>>> getSkillsDistribution(AbstractProgram abstractProgram, int maxDepth)
263    {
264        // If it is a program, the parentProgram that contains the skills is itself
265        if (abstractProgram instanceof Program program)
266        {
267            return getSkillsDistribution(program, program, maxDepth);
268        }
269        // If it is a subProgram not shared, retrieve the parent program that contains the skills
270        else if (abstractProgram instanceof SubProgram subProgram && !_odfHelper.isShared(subProgram))
271        {
272            // Since it is not shared, we can get the parent program
273            Program parentProgram = _odfHelper.getParentPrograms(subProgram).iterator().next();
274            
275            return getSkillsDistribution(parentProgram, subProgram, maxDepth);
276        }
277        
278        return null;
279    }
280    
281    /**
282     * Get the skills distribution by courses over a {@link ProgramItem}
283     * @param parentProgram The parent program that contains the skills
284     * @param program the program
285     * @param maxDepth the max depth of courses. For example, set to 1 to compute distribution over UE only, set 2 to compute distribution over UE and EC, ...
286     * @return the skills distribution as Map&lt;MacroSkill, Map&lt;MicroSkill, Set&lt;Course&gt;&gt;&gt;
287     */
288    public Map<Content, Map<Content, Set<Content>>> getSkillsDistribution(Program parentProgram, AbstractProgram program, int maxDepth)
289    {
290        try
291        {
292            // Map<MacroSkill, Map<MicroSkill, Set<Course>>>
293            Map<Content, Map<Content, Set<Content>>> skillsDistribution = new LinkedHashMap<>();
294            
295            if (!isExcluded(program))
296            {
297                // Get all macro skills. First skills, then transversal skills
298                List<Content> macroSkills = parentProgram.getSkills();
299                macroSkills.addAll(parentProgram.getTransversalSkills());
300                
301                // First initialize macro and micro skills to :
302                // 1. Keep the macro skills order defined in the program
303                // 2. Keep the micro skills order defined in the macro skill
304                for (Content skill: macroSkills)
305                {
306                    LinkedHashMap<Content, Set<Content>> microSkills = new LinkedHashMap<>();
307                    for (ContentValue microSkill : skill.getValueOrDefault("microSkills", new ContentValue[0]))
308                    {
309                        microSkills.put(microSkill.getContent(), new HashSet<>());
310                    }
311                    skillsDistribution.put(skill, microSkills);
312                }
313                
314                _buildSkillsDistribution(parentProgram, program, skillsDistribution, maxDepth);
315            }
316            
317            _buildSkillsDistribution(parentProgram, program, skillsDistribution, maxDepth);
318            
319            // Filter empty micro skills to keep only the ones defined in the program and with at least one course
320            skillsDistribution.entrySet().forEach(macroSkillEntry ->
321            {
322                Map<Content, Set<Content>> microSkills = macroSkillEntry.getValue();
323                microSkills.entrySet().removeIf(microSkillEntry -> microSkillEntry.getValue().isEmpty());
324            });
325            
326            // Then filter empty macro skills to keep only the ones with at least one micro skill
327            skillsDistribution.entrySet().removeIf(macroSkillEntry -> macroSkillEntry.getValue().isEmpty());
328            
329            return skillsDistribution;
330        }
331        catch (UnknownAmetysObjectException e)
332        {
333            getLogger().error("At least, one skill does not exists", e);
334            // Do not return a partial response, we prefer an empty one.
335            return Map.of();
336        }
337    }
338    
339    
340    private void _buildSkillsDistribution(Program parentProgram, ProgramItem programItem, Map<Content, Map<Content, Set<Content>>> skillsDistribution, int maxDepth) throws UnknownAmetysObjectException
341    {
342        if (programItem instanceof Course course)
343        {
344            // If it is a course, get its skills for the program
345            _buildSkillsDistribution(parentProgram, course, course, skillsDistribution, 1, maxDepth);
346        }
347        else
348        {
349            // If it is not a course, go through its course children
350            List<ProgramItem> children = _odfHelper.getChildProgramItems(programItem)
351                .stream()
352                .filter(Predicate.not(this::isExcluded))
353                .collect(Collectors.toList());
354            for (ProgramItem childProgramItem : children)
355            {
356                _buildSkillsDistribution(parentProgram, childProgramItem, skillsDistribution, maxDepth);
357            }
358        }
359    }
360    
361    private void _buildSkillsDistribution(Program parentProgram, Course course, Course parentCourse, Map<Content, Map<Content, Set<Content>>> skillsDistribution, int depth, int maxDepth) throws UnknownAmetysObjectException
362    {
363        // Get the micro skills of the course by program
364        List<? extends ModifiableRepeaterEntry> microSkillsByProgramEntries = Optional.of(course)
365                .map(e -> e.getRepeater(Course.ACQUIRED_MICRO_SKILLS))
366                .map(ModifiableRepeater::getEntries)
367                .orElse(List.of());
368
369        // Get the micro skills of the course for the program
370        ModifiableRepeaterEntry microSkillsForProgram = microSkillsByProgramEntries.stream()
371                                   .filter(entry -> ((ContentValue) entry.getValue("program")).getContentId().equals(parentProgram.getId()))
372                                   .findFirst()
373                                   .orElse(null);
374        
375        if (microSkillsForProgram != null)
376        {
377            // Get the micro skills
378            ContentValue[] microSkills = microSkillsForProgram.getValue(Course.ACQUIRED_MICRO_SKILLS_SKILLS);
379            
380            if (microSkills != null)
381            {
382                for (ContentValue microSkillContentValue : microSkills)
383                {
384                    Content microSkill = microSkillContentValue.getContent();
385                    ContentValue macroSkill = microSkill.getValue("parentMacroSkill");
386                    
387                    // Add the microSkill under the macro skill if it is not already
388                    // Map<MicroSkills, Set<Course>>
389                    Map<Content, Set<Content>> coursesByMicroSkills = skillsDistribution.computeIfAbsent(macroSkill.getContent(), __ -> new LinkedHashMap<>());
390                    
391                    // Add the course under the micro skill if it is not already
392                    Set<Content> coursesForMicroSkill = coursesByMicroSkills.computeIfAbsent(microSkill, __ -> new LinkedHashSet<>());
393                    coursesForMicroSkill.add(parentCourse);
394                }
395            }
396        }
397
398        if (depth < maxDepth)
399        {
400            // Get skills distribution over child courses
401            course.getCourseLists()
402                .stream()
403                .forEach(cl ->
404                {
405                    cl.getCourses()
406                        .stream().forEach(c ->
407                        {
408                            _buildSkillsDistribution(parentProgram, c, parentCourse, skillsDistribution, depth + 1, maxDepth);
409                        });
410                });
411        }
412    }
413
414    /**
415     * Get all micro skills of a requested catalog
416     * @param catalog The catalog
417     * @return The micro skills
418     */
419    public AmetysObjectIterable<Content> getMicroSkills(String catalog)
420    {
421        List<Expression> exprs = new ArrayList<>();
422        exprs.add(new ContentTypeExpression(Operator.EQ, SkillEditionFunction.MICRO_SKILL_TYPE));
423        exprs.add(new StringExpression("catalog", Operator.EQ, catalog));
424        Expression expression = new AndExpression(exprs.toArray(Expression[]::new));
425        
426        String query = ContentQueryHelper.getContentXPathQuery(expression);
427        return _resolver.<Content>query(query);
428    }
429    
430    /**
431     * Get the micro skills of a program
432     * @param program The program
433     * @return The microskills attached to the program
434     */
435    public Stream<String> getProgramMicroSkills(Program program)
436    {
437        Set<Content> macroSkills = new HashSet<>();
438        macroSkills.addAll(program.getSkills());
439        macroSkills.addAll(program.getTransversalSkills());
440        
441        return macroSkills
442            .stream()
443            .map(macroSkill -> macroSkill.<ContentValue[]>getValue("microSkills"))
444            .filter(Objects::nonNull)
445            .flatMap(Stream::of)
446            .map(ContentValue::getContentId);
447    }
448    
449    /**
450     * Get the micro skills linked to courses under a program
451     * @param program The program
452     * @return The micro skills
453     */
454    public Set<ContentValue> getReferencedMicroSkills(Program program)
455    {
456        return _getReferencedMicroSkills(program.getId(), program);
457    }
458    
459    /**
460     * Recursively get the micro skills referencd in given program item for the program
461     * @param programId The id of the program to check
462     * @param programItem The program item in which to check the micro skills
463     * @return The set of micro skills referenced in program item
464     */
465    protected Set<ContentValue> _getReferencedMicroSkills(String programId, ProgramItem programItem)
466    {
467        Set<ContentValue> microSkills = new HashSet<>();
468        if (programItem instanceof CourseContainer courseContainer)
469        {
470            List<Course> courses = courseContainer.getCourses();
471            for (Course course : courses)
472            {
473                microSkills.addAll(Arrays.asList(course.getAcquiredSkills(programId)));
474            }
475        }
476        
477        for (ProgramItem childProgramItem : _odfHelper.getChildProgramItems(programItem))
478        {
479            microSkills.addAll(_getReferencedMicroSkills(programId, childProgramItem));
480        }
481        
482        return microSkills;
483    }
484}