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.io.BufferedReader; 019import java.io.IOException; 020import java.nio.charset.StandardCharsets; 021import java.nio.file.Files; 022import java.nio.file.Paths; 023import java.util.ArrayList; 024import java.util.HashMap; 025import java.util.HashSet; 026import java.util.LinkedHashMap; 027import java.util.List; 028import java.util.Map; 029import java.util.Objects; 030import java.util.Optional; 031import java.util.Set; 032import java.util.function.Predicate; 033import java.util.stream.Collectors; 034 035import org.apache.avalon.framework.component.Component; 036import org.apache.avalon.framework.service.ServiceException; 037import org.apache.avalon.framework.service.ServiceManager; 038import org.apache.avalon.framework.service.Serviceable; 039import org.apache.commons.lang.ArrayUtils; 040import org.apache.commons.lang3.StringUtils; 041import org.supercsv.io.CsvListReader; 042import org.supercsv.io.ICsvListReader; 043import org.supercsv.prefs.CsvPreference; 044 045import org.ametys.cms.ObservationConstants; 046import org.ametys.cms.data.ContentValue; 047import org.ametys.cms.indexing.solr.SolrIndexHelper; 048import org.ametys.cms.repository.Content; 049import org.ametys.cms.repository.ContentQueryHelper; 050import org.ametys.cms.repository.ContentTypeExpression; 051import org.ametys.cms.repository.ModifiableContent; 052import org.ametys.cms.repository.WorkflowAwareContent; 053import org.ametys.cms.workflow.AbstractContentWorkflowComponent; 054import org.ametys.cms.workflow.ContentWorkflowHelper; 055import org.ametys.core.observation.Event; 056import org.ametys.core.observation.ObservationManager; 057import org.ametys.core.ui.Callable; 058import org.ametys.core.user.CurrentUserProvider; 059import org.ametys.odf.ODFHelper; 060import org.ametys.odf.ProgramItem; 061import org.ametys.odf.course.Course; 062import org.ametys.odf.enumeration.OdfReferenceTableEntry; 063import org.ametys.odf.enumeration.OdfReferenceTableHelper; 064import org.ametys.odf.program.AbstractProgram; 065import org.ametys.odf.program.Container; 066import org.ametys.plugins.repository.AmetysObjectIterable; 067import org.ametys.plugins.repository.AmetysObjectIterator; 068import org.ametys.plugins.repository.AmetysObjectResolver; 069import org.ametys.plugins.repository.AmetysRepositoryException; 070import org.ametys.plugins.repository.ModifiableAmetysObject; 071import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeater; 072import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeaterEntry; 073import org.ametys.plugins.repository.query.expression.AndExpression; 074import org.ametys.plugins.repository.query.expression.Expression.Operator; 075import org.ametys.plugins.repository.query.expression.StringExpression; 076import org.ametys.runtime.plugin.component.AbstractLogEnabled; 077 078import com.opensymphony.workflow.WorkflowException; 079 080/** 081 * ODF skills helper 082 */ 083public class ODFSkillsHelper extends AbstractLogEnabled implements Serviceable, Component 084{ 085 /** The avalon role. */ 086 public static final String ROLE = ODFSkillsHelper.class.getName(); 087 088 /** The skills other names attribute name */ 089 public static final String SKILL_OTHER_NAMES_ATTRIBUTE_NAME = "otherNames"; 090 091 /** The internal attribute name to excluded from skills */ 092 public static final String SKILLS_EXCLUDED_INTERNAL_ATTRIBUTE_NAME = "excluded"; 093 094 /** The content workflow helper */ 095 protected ContentWorkflowHelper _contentWorkflowHelper; 096 097 /** The ametys object resolver */ 098 protected AmetysObjectResolver _resolver; 099 100 /** The ODF helper */ 101 protected ODFHelper _odfHelper; 102 103 /** The observation manager */ 104 protected ObservationManager _observationManager; 105 106 /** The Solr index helper */ 107 protected SolrIndexHelper _solrIndexHelper; 108 109 /** The current user provider */ 110 protected CurrentUserProvider _currentUserProvider; 111 112 public void service(ServiceManager manager) throws ServiceException 113 { 114 _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE); 115 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 116 _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE); 117 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 118 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 119 } 120 121 /** 122 * Exclude or include the program items from skills display 123 * @param programItemIds the list of program item ids 124 * @param excluded <code>true</code> if the program items need to be excluded. 125 * @return the map of changed program items properties 126 */ 127 @Callable 128 public Map<String, Object> setProgramItemsExclusion(List<String> programItemIds, boolean excluded) 129 { 130 Map<String, Object> results = new HashMap<>(); 131 results.put("allright-program-items", new ArrayList<>()); 132 133 for (String programItemId : programItemIds) 134 { 135 ProgramItem programItem = _resolver.resolveById(programItemId); 136 if (programItem instanceof AbstractProgram || programItem instanceof Container) 137 { 138 ((Content) programItem).getInternalDataHolder().setValue(SKILLS_EXCLUDED_INTERNAL_ATTRIBUTE_NAME, excluded); 139 ((ModifiableAmetysObject) programItem).saveChanges(); 140 141 Map<String, Object> programItem2Json = new HashMap<>(); 142 programItem2Json.put("id", programItem.getId()); 143 programItem2Json.put("title", ((Content) programItem).getTitle()); 144 145 @SuppressWarnings("unchecked") 146 List<Map<String, Object>> allRightProgramItems = (List<Map<String, Object>>) results.get("allright-program-items"); 147 allRightProgramItems.add(programItem2Json); 148 149 Map<String, Object> eventParams = new HashMap<>(); 150 eventParams.put(ObservationConstants.ARGS_CONTENT, programItem); 151 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, programItem.getId()); 152 eventParams.put(org.ametys.odf.observation.OdfObservationConstants.ODF_CONTENT_SKILLS_EXCLUSION_ARG, excluded); 153 _observationManager.notify(new Event(org.ametys.odf.observation.OdfObservationConstants.ODF_CONTENT_SKILLS_EXCLUSION_CHANGED, _currentUserProvider.getUser(), eventParams)); 154 } 155 } 156 157 return results; 158 159 } 160 161 /** 162 * <code>true</code> if the program item is excluded from skills display 163 * @param programItem the program item 164 * @return <code>true</code> if the program item is excluded from skills display 165 */ 166 public boolean isExcluded(ProgramItem programItem) 167 { 168 if (programItem instanceof AbstractProgram || programItem instanceof Container) 169 { 170 return ((Content) programItem).getInternalDataHolder().getValue(SKILLS_EXCLUDED_INTERNAL_ATTRIBUTE_NAME, false); 171 } 172 173 return false; 174 } 175 176 /** 177 * Get the computed skill values for a given {@link AbstractProgram} 178 * @param abstractProgram the abstract program 179 * @param maxDepth the max depth of courses. For example, set to 1 to compute skills over UE only, set 2 to compute skills over UE and EC, ... 180 * @return the skill values computed from the attached courses 181 */ 182 public Set<ContentValue> getComputedSkills(AbstractProgram abstractProgram, int maxDepth) 183 { 184 return _computeSkills(abstractProgram, 1, maxDepth); 185 } 186 187 private Set<ContentValue> _computeSkills(ProgramItem programItem, int depth, int maxDepth) 188 { 189 Set<ContentValue> skills = new HashSet<>(); 190 191 if (programItem instanceof Course) 192 { 193 // Get skills of current course 194 ModifiableModelAwareRepeater repeaterSkills = ((Course) programItem).getRepeater(Course.ACQUIRED_SKILLS); 195 if (repeaterSkills != null) 196 { 197 List< ? extends ModifiableModelAwareRepeaterEntry> entries = repeaterSkills.getEntries(); 198 for (ModifiableModelAwareRepeaterEntry entry : entries) 199 { 200 ModifiableModelAwareRepeater repeater = entry.getRepeater(Course.ACQUIRED_SKILLS_SKILLS); 201 if (repeater != null) 202 { 203 skills.addAll(repeater.getEntries().stream() 204 .map(e -> (ContentValue) e.getValue(Course.ACQUIRED_SKILLS_SKILLS_SKILL, false, null)) 205 .filter(Objects::nonNull) 206 .collect(Collectors.toSet())); 207 } 208 } 209 } 210 211 if (depth < maxDepth) 212 { 213 ((Course) programItem).getCourseLists() 214 .stream() 215 .forEach(cl -> 216 { 217 cl.getCourses() 218 .stream().forEach(c -> 219 { 220 skills.addAll(_computeSkills(c, depth + 1, maxDepth)); 221 }); 222 }); 223 } 224 } 225 else 226 { 227 List<ProgramItem> childProgramItems = _odfHelper.getChildProgramItems(programItem); 228 for (ProgramItem childProgramItem : childProgramItems) 229 { 230 skills.addAll(_computeSkills(childProgramItem, depth, maxDepth)); 231 } 232 } 233 234 return skills; 235 } 236 237 /** 238 * Get the skills distribution by courses over a {@link ProgramItem} 239 * Distribution is computed over the course of first level only 240 * @param programItem the program item 241 * @return the skills distribution 242 */ 243 public Map<Content, Map<Content, Map<Content, Content>>> getSkillsDistribution(ProgramItem programItem) 244 { 245 return getSkillsDistribution(programItem, 1); 246 } 247 248 /** 249 * Get the skills distribution by courses over a {@link ProgramItem} 250 * @param programItem the program item 251 * @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, ... 252 * @return the skills distribution as Map<SkillSet, Map<Skill, Map<Course, AcquisitionLevel>>> 253 */ 254 public Map<Content, Map<Content, Map<Content, Content>>> getSkillsDistribution(ProgramItem programItem, int maxDepth) 255 { 256 // Map<SkillSet, Map<Skill, Map<Course, AcquisitionLevel>>> 257 Map<Content, Map<Content, Map<Content, Content>>> skillsDistribution = new LinkedHashMap<>(); 258 259 if (!isExcluded(programItem)) 260 { 261 _buildSkillsDistribution(programItem, skillsDistribution, maxDepth); 262 } 263 264 return skillsDistribution; 265 } 266 267 268 269 private void _buildSkillsDistribution(ProgramItem programItem, Map<Content, Map<Content, Map<Content, Content>>> skillsDistribution, int maxDepth) 270 { 271 if (programItem instanceof Course) 272 { 273 _buildSkillsDistribution((Course) programItem, (Course) programItem, skillsDistribution, 1, maxDepth); 274 } 275 else 276 { 277 List<ProgramItem> children = _odfHelper.getChildProgramItems(programItem) 278 .stream() 279 .filter(Predicate.not(this::isExcluded)) 280 .collect(Collectors.toList()); 281 for (ProgramItem childProgramItem : children) 282 { 283 _buildSkillsDistribution(childProgramItem, skillsDistribution, maxDepth); 284 } 285 } 286 } 287 288 private void _buildSkillsDistribution(Course course, Course parentCourse, Map<Content, Map<Content, Map<Content, Content>>> skillsDistribution, int depth, int maxDepth) 289 { 290 List<? extends ModifiableModelAwareRepeaterEntry> acquiredSkillEntries = Optional.of(course) 291 .map(e -> e.getRepeater(Course.ACQUIRED_SKILLS)) 292 .map(ModifiableModelAwareRepeater::getEntries) 293 .orElse(List.of()); 294 295 for (ModifiableModelAwareRepeaterEntry acquiredSkillEntry : acquiredSkillEntries) 296 { 297 Optional.of(acquiredSkillEntry) 298 .map(e -> e.<ContentValue>getValue(Course.ACQUIRED_SKILLS_SKILLSET)) 299 .flatMap(ContentValue::getContentIfExists) 300 .ifPresent( 301 skillSet -> 302 { 303 Map<Content, Map<Content, Content>> skills = skillsDistribution.computeIfAbsent(skillSet, __ -> new LinkedHashMap<>()); 304 305 List<? extends ModifiableModelAwareRepeaterEntry> skillEntries = Optional.of(acquiredSkillEntry) 306 .map(e -> e.getRepeater(Course.ACQUIRED_SKILLS_SKILLS)) 307 .map(ModifiableModelAwareRepeater::getEntries) 308 .orElse(List.of()); 309 310 for (ModifiableModelAwareRepeaterEntry skillEntry : skillEntries) 311 { 312 Content skill = Optional.of(skillEntry) 313 .map(entry -> entry.<ContentValue>getValue(Course.ACQUIRED_SKILLS_SKILLS_SKILL)) 314 .flatMap(ContentValue::getContentIfExists) 315 .orElse(null); 316 317 if (skill != null) 318 { 319 Content acquisitionLevel = 320 Optional.of(skillEntry) 321 .map(entry -> entry.<ContentValue>getValue(Course.ACQUIRED_SKILLS_SKILLS_ACQUISITION_LEVEL)) 322 .flatMap(ContentValue::getContentIfExists) 323 .orElse(null); 324 325 Map<Content, Content> courses = skills.computeIfAbsent(skill, s -> new LinkedHashMap<>()); 326 courses.put(parentCourse, _getMaxAcquisitionLevel(acquisitionLevel, courses.get(parentCourse))); 327 } 328 } 329 } 330 ); 331 } 332 333 if (depth < maxDepth) 334 { 335 // Get skills distribution over child courses 336 course.getCourseLists() 337 .stream() 338 .forEach(cl -> 339 { 340 cl.getCourses() 341 .stream().forEach(c -> 342 { 343 _buildSkillsDistribution(c, parentCourse, skillsDistribution, depth + 1, maxDepth); 344 }); 345 }); 346 } 347 } 348 349 private Content _getMaxAcquisitionLevel(Content level1, Content level2) 350 { 351 if (level1 == null) 352 { 353 return level2; 354 } 355 356 if (level2 == null) 357 { 358 return level1; 359 } 360 361 long order1 = level1.getValue(OdfReferenceTableEntry.ORDER, false, -1L); 362 long order2 = level2.getValue(OdfReferenceTableEntry.ORDER, false, -1L); 363 364 if (order1 >= order2) 365 { 366 return level1; 367 } 368 else 369 { 370 return level2; 371 } 372 } 373 374 /** 375 * Create all skills from ESCO file 376 * @param skillsCSVFilePath the skills CSV file path 377 */ 378 public void createSkillsFromESCOFileCSV(String skillsCSVFilePath) 379 { 380 String[] handledEvents = new String[] { 381 ObservationConstants.EVENT_CONTENT_ADDED, 382 ObservationConstants.EVENT_CONTENT_MODIFIED, 383 ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED, 384 ObservationConstants.EVENT_CONTENT_TAGGED, 385 ObservationConstants.EVENT_CONTENT_DELETED, 386 }; 387 388 try 389 { 390 _solrIndexHelper.pauseSolrCommitForEvents(handledEvents); 391 for (Skill skill : _getSkillsFromCSVFile(skillsCSVFilePath)) 392 { 393 try 394 { 395 _createSkillTableRef(skill); 396 } 397 catch (AmetysRepositoryException | WorkflowException e) 398 { 399 getLogger().warn("An error occurred creating skill with label {}", skill.getLabel(), e); 400 } 401 } 402 } 403 finally 404 { 405 _solrIndexHelper.restartSolrCommitForEvents(handledEvents); 406 } 407 } 408 409 /** 410 * Get the list of skills from the csv file 411 * @param skillsCSVFilePath the skills CSV file path 412 * @return the list of skills 413 */ 414 protected List<Skill> _getSkillsFromCSVFile(String skillsCSVFilePath) 415 { 416 List<Skill> skills = new ArrayList<>(); 417 try (BufferedReader reader = Files.newBufferedReader(Paths.get(skillsCSVFilePath), StandardCharsets.UTF_8); 418 ICsvListReader listReader = new CsvListReader(reader, CsvPreference.STANDARD_PREFERENCE)) 419 { 420 listReader.getHeader(true); //Skip header 421 422 List<String> read = listReader.read(); 423 while (read != null) 424 { 425 String conceptUri = read.get(1); // Uri 426 String label = read.get(4); // Get label 427 if (StringUtils.isNotBlank(label)) 428 { 429 String otherNamesAsString = read.get(5); // Get other names 430 String[] otherNames = StringUtils.isNotBlank(otherNamesAsString) ? StringUtils.split(otherNamesAsString, "\n") : ArrayUtils.EMPTY_STRING_ARRAY; 431 skills.add(new Skill(label, otherNames, conceptUri)); 432 } 433 read = listReader.read(); 434 } 435 } 436 catch (IOException e) 437 { 438 getLogger().warn("An error occurred parsing file {}", skillsCSVFilePath, e); 439 } 440 441 getLogger().info("Find {} skills into file {}", skills.size(), skillsCSVFilePath); 442 443 return skills; 444 } 445 446 /** 447 * Create a skill table ref content from the skill object 448 * @param skill the skill object 449 * @throws AmetysRepositoryException if a repository error occurred 450 * @throws WorkflowException if a workflow error occurred 451 */ 452 protected void _createSkillTableRef(Skill skill) throws AmetysRepositoryException, WorkflowException 453 { 454 String uri = skill.getConceptUri(); 455 String titleFR = skill.getLabel(); 456 String[] otherNames = skill.getOtherNames(); 457 458 ContentTypeExpression cTypeExpr = new ContentTypeExpression(Operator.EQ, OdfReferenceTableHelper.SKILL); 459 StringExpression codeExpr = new StringExpression(OdfReferenceTableEntry.CODE, Operator.EQ, uri); 460 461 String xpathQuery = ContentQueryHelper.getContentXPathQuery(new AndExpression(cTypeExpr, codeExpr)); 462 AmetysObjectIterable<ModifiableContent> contents = _resolver.query(xpathQuery); 463 AmetysObjectIterator<ModifiableContent> it = contents.iterator(); 464 465 if (!it.hasNext()) 466 { 467 Map<String, String> titleVariants = new HashMap<>(); 468 titleVariants.put("fr", titleFR); 469 470 Map<String, Object> result = _contentWorkflowHelper.createContent("reference-table", 1, titleFR, titleVariants, new String[] {OdfReferenceTableHelper.SKILL}, new String[0]); 471 ModifiableContent content = (ModifiableContent) result.get(AbstractContentWorkflowComponent.CONTENT_KEY); 472 473 content.setValue(OdfReferenceTableEntry.CODE, uri); 474 475 if (otherNames.length > 0) 476 { 477 content.setValue(SKILL_OTHER_NAMES_ATTRIBUTE_NAME, otherNames); 478 } 479 480 content.saveChanges(); 481 _contentWorkflowHelper.doAction((WorkflowAwareContent) content, 22); 482 483 getLogger().info("Skill's content \"{}\" ({}) was successfully created", titleFR, content.getId()); 484 } 485 } 486 487 private static class Skill 488 { 489 private String _label; 490 private String[] _otherNames; 491 private String _conceptUri; 492 493 public Skill(String label, String[] otherNames, String conceptUri) 494 { 495 _label = label; 496 _otherNames = otherNames; 497 _conceptUri = conceptUri; 498 } 499 500 public String getLabel() 501 { 502 return _label; 503 } 504 505 public String[] getOtherNames() 506 { 507 return _otherNames; 508 } 509 510 public String getConceptUri() 511 { 512 return _conceptUri; 513 } 514 } 515}