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