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.Arrays; 020import java.util.HashMap; 021import java.util.HashSet; 022import java.util.LinkedHashMap; 023import java.util.LinkedHashSet; 024import java.util.List; 025import java.util.Map; 026import java.util.Objects; 027import java.util.Optional; 028import java.util.Set; 029import java.util.function.Predicate; 030import java.util.stream.Collectors; 031import java.util.stream.Stream; 032 033import org.apache.avalon.framework.component.Component; 034import org.apache.avalon.framework.service.ServiceException; 035import org.apache.avalon.framework.service.ServiceManager; 036import org.apache.avalon.framework.service.Serviceable; 037 038import org.ametys.cms.ObservationConstants; 039import org.ametys.cms.contenttype.ContentTypesHelper; 040import org.ametys.cms.data.ContentValue; 041import org.ametys.cms.indexing.solr.SolrIndexHelper; 042import org.ametys.cms.repository.Content; 043import org.ametys.cms.repository.ContentDAO; 044import org.ametys.cms.repository.ContentQueryHelper; 045import org.ametys.cms.repository.ContentTypeExpression; 046import org.ametys.core.observation.Event; 047import org.ametys.core.observation.ObservationManager; 048import org.ametys.core.right.RightManager; 049import org.ametys.core.right.RightManager.RightResult; 050import org.ametys.core.ui.Callable; 051import org.ametys.core.user.CurrentUserProvider; 052import org.ametys.odf.ODFHelper; 053import org.ametys.odf.ProgramItem; 054import org.ametys.odf.course.Course; 055import org.ametys.odf.course.CourseContainer; 056import org.ametys.odf.program.AbstractProgram; 057import org.ametys.odf.program.Container; 058import org.ametys.odf.program.Program; 059import org.ametys.odf.program.SubProgram; 060import org.ametys.odf.skill.workflow.SkillEditionFunction; 061import org.ametys.plugins.repository.AmetysObjectIterable; 062import org.ametys.plugins.repository.AmetysObjectResolver; 063import org.ametys.plugins.repository.ModifiableAmetysObject; 064import org.ametys.plugins.repository.UnknownAmetysObjectException; 065import org.ametys.plugins.repository.data.holder.group.ModifiableRepeater; 066import org.ametys.plugins.repository.data.holder.group.ModifiableRepeaterEntry; 067import org.ametys.plugins.repository.query.expression.AndExpression; 068import org.ametys.plugins.repository.query.expression.Expression; 069import org.ametys.plugins.repository.query.expression.Expression.Operator; 070import org.ametys.plugins.repository.query.expression.StringExpression; 071import org.ametys.runtime.config.Config; 072import org.ametys.runtime.plugin.component.AbstractLogEnabled; 073 074/** 075 * ODF skills helper 076 */ 077public class ODFSkillsHelper extends AbstractLogEnabled implements Serviceable, Component 078{ 079 /** The avalon role. */ 080 public static final String ROLE = ODFSkillsHelper.class.getName(); 081 082 /** The internal attribute name to excluded from skills */ 083 public static final String SKILLS_EXCLUDED_INTERNAL_ATTRIBUTE_NAME = "excluded"; 084 085 /** The right to edit, create, delete or import transversal skills */ 086 public static final String RIGHT_TRANSVERSAL_SKILLS_HANDLE = "ODF_Rights_Transversal_Skills_Handle"; 087 /** The right to edit, create or import disciplinary skills */ 088 public static final String RIGHT_DISCIPLINARY_SKILLS_HANDLE = "ODF_Rights_Disciplinary_Skills_Handle"; 089 /** The right to add skills to programs */ 090 public static final String RIGHT_SKILLS_PROGRAM_FIELDS = "ODF_Rights_Skills_Program_Fields"; 091 092 /** The right to export all skills */ 093 public static final String RIGHT_SKILLS_EXPORT = "ODF_Rights_Skills_Export"; 094 095 /** The ametys object resolver */ 096 protected AmetysObjectResolver _resolver; 097 098 /** The content types helper */ 099 protected ContentTypesHelper _contentTypesHelper; 100 101 /** The content dao */ 102 protected ContentDAO _contentDao; 103 104 /** The ODF helper */ 105 protected ODFHelper _odfHelper; 106 107 /** The observation manager */ 108 protected ObservationManager _observationManager; 109 110 /** The Solr index helper */ 111 protected SolrIndexHelper _solrIndexHelper; 112 113 /** The current user provider */ 114 protected CurrentUserProvider _currentUserProvider; 115 116 /** The right manager */ 117 protected RightManager _rightManager; 118 119 public void service(ServiceManager manager) throws ServiceException 120 { 121 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 122 _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 123 _contentDao = (ContentDAO) manager.lookup(ContentDAO.ROLE); 124 _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE); 125 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 126 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 127 _rightManager = (RightManager) manager.lookup(RightManager.ROLE); 128 } 129 130 /** 131 * Determines if rules are enabled 132 * @return <code>true</code> if rules are enabled 133 */ 134 public static boolean isSkillsEnabled() 135 { 136 return Config.getInstance().getValue("odf.skills.enabled", false, false); 137 } 138 139 /** 140 * Get the path of the ODF root content 141 * @return The path of the ODF root content 142 */ 143 @Callable (rights = Callable.NO_CHECK_REQUIRED) 144 public String getOdfRootContentPath() 145 { 146 return _odfHelper.getRootContent(false).getPath(); 147 } 148 149 /** 150 * Trash transversal macro skills 151 * @param contentIds The macro skill ids 152 * @return the deleted and undeleted contents 153 */ 154 @Callable (rights = RIGHT_TRANSVERSAL_SKILLS_HANDLE) 155 public Map<String, Object> forceTrashTransversalMacroSkills(List<String> contentIds) 156 { 157 List<Content> contents = contentIds.stream() 158 .map(_resolver::<Content>resolveById) 159 .toList(); 160 161 if (contents.stream().anyMatch(content -> !_contentTypesHelper.isInstanceOf(content, SkillEditionFunction.TRANSVERSAL_MACRO_SKILL_TYPE))) 162 { 163 throw new IllegalArgumentException("Tried to delete transversal macro skills without sufficient right"); 164 } 165 166 // Call the content dao with null right because we already checked the right 167 return _contentDao.forceTrashContentsObj( 168 contents, 169 null 170 ); 171 } 172 173 /** 174 * Exclude or include the program items from skills display 175 * @param programItemIds the list of program item ids 176 * @param excluded <code>true</code> if the program items need to be excluded. 177 * @return the map of changed program items properties 178 */ 179 @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION) 180 public Map<String, Object> setProgramItemsExclusion(List<String> programItemIds, boolean excluded) 181 { 182 Map<String, Object> results = new HashMap<>(); 183 results.put("allright-program-items", new ArrayList<>()); 184 results.put("noright-program-items", new ArrayList<>()); 185 186 for (String programItemId : programItemIds) 187 { 188 ProgramItem programItem = _resolver.resolveById(programItemId); 189 if (programItem instanceof AbstractProgram || programItem instanceof Container) 190 { 191 Map<String, Object> programItem2Json = new HashMap<>(); 192 programItem2Json.put("id", programItem.getId()); 193 programItem2Json.put("title", ((Content) programItem).getTitle()); 194 195 if (_rightManager.hasRight(_currentUserProvider.getUser(), "ODF_Right_Skills_Excluded", programItem) == RightResult.RIGHT_ALLOW) 196 { 197 ((Content) programItem).getInternalDataHolder().setValue(SKILLS_EXCLUDED_INTERNAL_ATTRIBUTE_NAME, excluded); 198 ((ModifiableAmetysObject) programItem).saveChanges(); 199 200 @SuppressWarnings("unchecked") 201 List<Map<String, Object>> allRightProgramItems = (List<Map<String, Object>>) results.get("allright-program-items"); 202 allRightProgramItems.add(programItem2Json); 203 204 Map<String, Object> eventParams = new HashMap<>(); 205 eventParams.put(ObservationConstants.ARGS_CONTENT, programItem); 206 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, programItem.getId()); 207 eventParams.put(org.ametys.odf.observation.OdfObservationConstants.ODF_CONTENT_SKILLS_EXCLUSION_ARG, excluded); 208 _observationManager.notify(new Event(org.ametys.odf.observation.OdfObservationConstants.ODF_CONTENT_SKILLS_EXCLUSION_CHANGED, _currentUserProvider.getUser(), eventParams)); 209 } 210 else 211 { 212 @SuppressWarnings("unchecked") 213 List<Map<String, Object>> noRightProgramItems = (List<Map<String, Object>>) results.get("noright-program-items"); 214 noRightProgramItems.add(programItem2Json); 215 } 216 } 217 } 218 219 return results; 220 221 } 222 223 /** 224 * <code>true</code> if the program item is excluded from skills display 225 * @param programItem the program item 226 * @return <code>true</code> if the program item is excluded from skills display 227 */ 228 public boolean isExcluded(ProgramItem programItem) 229 { 230 // If the skills are not enabled, every item is excluded 231 if (!isSkillsEnabled()) 232 { 233 return true; 234 } 235 236 if (programItem instanceof AbstractProgram || programItem instanceof Container) 237 { 238 return ((Content) programItem).getInternalDataHolder().getValueOrDefault(SKILLS_EXCLUDED_INTERNAL_ATTRIBUTE_NAME, false); 239 } 240 241 return false; 242 } 243 244 /** 245 * Get the skills distribution by courses over a {@link AbstractProgram} 246 * Distribution is computed over the course of first level only 247 * @param abstractProgram The program or subProgram for which to get the skills distribution 248 * @return the skills distribution or null if the content is not a program or a compatible subProgram 249 */ 250 public Map<Content, Map<Content, Set<Content>>> getSkillsDistribution(AbstractProgram abstractProgram) 251 { 252 return getSkillsDistribution(abstractProgram, 1); 253 } 254 255 /** 256 * Get the skills distribution by courses over a {@link AbstractProgram} 257 * Distribution is computed over the course of first level only 258 * @param abstractProgram The program or subProgram for which to get the skills distribution 259 * @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, ... 260 * @return the skills distribution or null if the content is not a program or a compatible subProgram 261 */ 262 public Map<Content, Map<Content, Set<Content>>> getSkillsDistribution(AbstractProgram abstractProgram, int maxDepth) 263 { 264 // If it is a program, the parentProgram that contains the skills is itself 265 if (abstractProgram instanceof Program program) 266 { 267 return getSkillsDistribution(program, program, maxDepth); 268 } 269 // If it is a subProgram not shared, retrieve the parent program that contains the skills 270 else if (abstractProgram instanceof SubProgram subProgram && !_odfHelper.isShared(subProgram)) 271 { 272 // Since it is not shared, we can get the parent program 273 Program parentProgram = _odfHelper.getParentPrograms(subProgram).iterator().next(); 274 275 return getSkillsDistribution(parentProgram, subProgram, maxDepth); 276 } 277 278 return null; 279 } 280 281 /** 282 * Get the skills distribution by courses over a {@link ProgramItem} 283 * @param parentProgram The parent program that contains the skills 284 * @param program the program 285 * @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, ... 286 * @return the skills distribution as Map<MacroSkill, Map<MicroSkill, Set<Course>>> 287 */ 288 public Map<Content, Map<Content, Set<Content>>> getSkillsDistribution(Program parentProgram, AbstractProgram program, int maxDepth) 289 { 290 try 291 { 292 // Map<MacroSkill, Map<MicroSkill, Set<Course>>> 293 Map<Content, Map<Content, Set<Content>>> skillsDistribution = new LinkedHashMap<>(); 294 295 if (!isExcluded(program)) 296 { 297 // Get all macro skills. First skills, then transversal skills 298 List<Content> macroSkills = parentProgram.getSkills(); 299 macroSkills.addAll(parentProgram.getTransversalSkills()); 300 301 // First initialize macro and micro skills to : 302 // 1. Keep the macro skills order defined in the program 303 // 2. Keep the micro skills order defined in the macro skill 304 for (Content skill: macroSkills) 305 { 306 LinkedHashMap<Content, Set<Content>> microSkills = new LinkedHashMap<>(); 307 for (ContentValue microSkill : skill.getValueOrDefault("microSkills", new ContentValue[0])) 308 { 309 microSkills.put(microSkill.getContent(), new HashSet<>()); 310 } 311 skillsDistribution.put(skill, microSkills); 312 } 313 314 _buildSkillsDistribution(parentProgram, program, skillsDistribution, maxDepth); 315 } 316 317 _buildSkillsDistribution(parentProgram, program, skillsDistribution, maxDepth); 318 319 // Filter empty micro skills to keep only the ones defined in the program and with at least one course 320 skillsDistribution.entrySet().forEach(macroSkillEntry -> 321 { 322 Map<Content, Set<Content>> microSkills = macroSkillEntry.getValue(); 323 microSkills.entrySet().removeIf(microSkillEntry -> microSkillEntry.getValue().isEmpty()); 324 }); 325 326 // Then filter empty macro skills to keep only the ones with at least one micro skill 327 skillsDistribution.entrySet().removeIf(macroSkillEntry -> macroSkillEntry.getValue().isEmpty()); 328 329 return skillsDistribution; 330 } 331 catch (UnknownAmetysObjectException e) 332 { 333 getLogger().error("At least, one skill does not exists", e); 334 // Do not return a partial response, we prefer an empty one. 335 return Map.of(); 336 } 337 } 338 339 340 private void _buildSkillsDistribution(Program parentProgram, ProgramItem programItem, Map<Content, Map<Content, Set<Content>>> skillsDistribution, int maxDepth) throws UnknownAmetysObjectException 341 { 342 if (programItem instanceof Course course) 343 { 344 // If it is a course, get its skills for the program 345 _buildSkillsDistribution(parentProgram, course, course, skillsDistribution, 1, maxDepth); 346 } 347 else 348 { 349 // If it is not a course, go through its course children 350 List<ProgramItem> children = _odfHelper.getChildProgramItems(programItem) 351 .stream() 352 .filter(Predicate.not(this::isExcluded)) 353 .collect(Collectors.toList()); 354 for (ProgramItem childProgramItem : children) 355 { 356 _buildSkillsDistribution(parentProgram, childProgramItem, skillsDistribution, maxDepth); 357 } 358 } 359 } 360 361 private void _buildSkillsDistribution(Program parentProgram, Course course, Course parentCourse, Map<Content, Map<Content, Set<Content>>> skillsDistribution, int depth, int maxDepth) throws UnknownAmetysObjectException 362 { 363 // Get the micro skills of the course by program 364 List<? extends ModifiableRepeaterEntry> microSkillsByProgramEntries = Optional.of(course) 365 .map(e -> e.getRepeater(Course.ACQUIRED_MICRO_SKILLS)) 366 .map(ModifiableRepeater::getEntries) 367 .orElse(List.of()); 368 369 // Get the micro skills of the course for the program 370 ModifiableRepeaterEntry microSkillsForProgram = microSkillsByProgramEntries.stream() 371 .filter(entry -> ((ContentValue) entry.getValue("program")).getContentId().equals(parentProgram.getId())) 372 .findFirst() 373 .orElse(null); 374 375 if (microSkillsForProgram != null) 376 { 377 // Get the micro skills 378 ContentValue[] microSkills = microSkillsForProgram.getValue(Course.ACQUIRED_MICRO_SKILLS_SKILLS); 379 380 if (microSkills != null) 381 { 382 for (ContentValue microSkillContentValue : microSkills) 383 { 384 Content microSkill = microSkillContentValue.getContent(); 385 ContentValue macroSkill = microSkill.getValue("parentMacroSkill"); 386 387 // Add the microSkill under the macro skill if it is not already 388 // Map<MicroSkills, Set<Course>> 389 Map<Content, Set<Content>> coursesByMicroSkills = skillsDistribution.computeIfAbsent(macroSkill.getContent(), __ -> new LinkedHashMap<>()); 390 391 // Add the course under the micro skill if it is not already 392 Set<Content> coursesForMicroSkill = coursesByMicroSkills.computeIfAbsent(microSkill, __ -> new LinkedHashSet<>()); 393 coursesForMicroSkill.add(parentCourse); 394 } 395 } 396 } 397 398 if (depth < maxDepth) 399 { 400 // Get skills distribution over child courses 401 course.getCourseLists() 402 .stream() 403 .forEach(cl -> 404 { 405 cl.getCourses() 406 .stream().forEach(c -> 407 { 408 _buildSkillsDistribution(parentProgram, c, parentCourse, skillsDistribution, depth + 1, maxDepth); 409 }); 410 }); 411 } 412 } 413 414 /** 415 * Get all micro skills of a requested catalog 416 * @param catalog The catalog 417 * @return The micro skills 418 */ 419 public AmetysObjectIterable<Content> getMicroSkills(String catalog) 420 { 421 List<Expression> exprs = new ArrayList<>(); 422 exprs.add(new ContentTypeExpression(Operator.EQ, SkillEditionFunction.MICRO_SKILL_TYPE)); 423 exprs.add(new StringExpression("catalog", Operator.EQ, catalog)); 424 Expression expression = new AndExpression(exprs.toArray(Expression[]::new)); 425 426 String query = ContentQueryHelper.getContentXPathQuery(expression); 427 return _resolver.<Content>query(query); 428 } 429 430 /** 431 * Get the micro skills of a program 432 * @param program The program 433 * @return The microskills attached to the program 434 */ 435 public Stream<String> getProgramMicroSkills(Program program) 436 { 437 Set<Content> macroSkills = new HashSet<>(); 438 macroSkills.addAll(program.getSkills()); 439 macroSkills.addAll(program.getTransversalSkills()); 440 441 return macroSkills 442 .stream() 443 .map(macroSkill -> macroSkill.<ContentValue[]>getValue("microSkills")) 444 .filter(Objects::nonNull) 445 .flatMap(Stream::of) 446 .map(ContentValue::getContentId); 447 } 448 449 /** 450 * Get the micro skills linked to courses under a program 451 * @param program The program 452 * @return The micro skills 453 */ 454 public Set<ContentValue> getReferencedMicroSkills(Program program) 455 { 456 return _getReferencedMicroSkills(program.getId(), program); 457 } 458 459 /** 460 * Recursively get the micro skills referencd in given program item for the program 461 * @param programId The id of the program to check 462 * @param programItem The program item in which to check the micro skills 463 * @return The set of micro skills referenced in program item 464 */ 465 protected Set<ContentValue> _getReferencedMicroSkills(String programId, ProgramItem programItem) 466 { 467 Set<ContentValue> microSkills = new HashSet<>(); 468 if (programItem instanceof CourseContainer courseContainer) 469 { 470 List<Course> courses = courseContainer.getCourses(); 471 for (Course course : courses) 472 { 473 microSkills.addAll(Arrays.asList(course.getAcquiredSkills(programId))); 474 } 475 } 476 477 for (ProgramItem childProgramItem : _odfHelper.getChildProgramItems(programItem)) 478 { 479 microSkills.addAll(_getReferencedMicroSkills(programId, childProgramItem)); 480 } 481 482 return microSkills; 483 } 484}