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