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<MacroSkill, Map<MicroSkill, Set<Course>>> 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}