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