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