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