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