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