001/* 002 * Copyright 2025 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.catalog; 017 018import java.util.ArrayList; 019import java.util.Arrays; 020import java.util.HashMap; 021import java.util.List; 022import java.util.Map; 023import java.util.Objects; 024import java.util.Optional; 025import java.util.stream.Stream; 026 027import org.apache.avalon.framework.service.ServiceException; 028import org.apache.avalon.framework.service.ServiceManager; 029import org.apache.avalon.framework.service.Serviceable; 030 031import org.ametys.cms.ObservationConstants; 032import org.ametys.cms.data.ContentValue; 033import org.ametys.cms.repository.Content; 034import org.ametys.cms.repository.ContentQueryHelper; 035import org.ametys.cms.repository.ContentTypeExpression; 036import org.ametys.cms.repository.DefaultContent; 037import org.ametys.cms.repository.ModifiableContent; 038import org.ametys.cms.repository.ModifiableDefaultContent; 039import org.ametys.cms.repository.WorkflowAwareContent; 040import org.ametys.cms.workflow.ContentWorkflowHelper; 041import org.ametys.cms.workflow.EditContentFunction; 042import org.ametys.core.observation.Event; 043import org.ametys.core.observation.ObservationManager; 044import org.ametys.core.user.CurrentUserProvider; 045import org.ametys.odf.ODFHelper; 046import org.ametys.odf.course.Course; 047import org.ametys.odf.program.Program; 048import org.ametys.odf.program.ProgramFactory; 049import org.ametys.odf.skill.ODFSkillsHelper; 050import org.ametys.odf.skill.workflow.SkillEditionFunction; 051import org.ametys.plugins.repository.AmetysObjectResolver; 052import org.ametys.plugins.repository.AmetysRepositoryException; 053import org.ametys.plugins.repository.ModifiableAmetysObject; 054import org.ametys.plugins.repository.ModifiableTraversableAmetysObject; 055import org.ametys.plugins.repository.RemovableAmetysObject; 056import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeater; 057import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeaterEntry; 058import org.ametys.plugins.repository.jcr.NameHelper; 059import org.ametys.plugins.repository.lock.LockableAmetysObject; 060import org.ametys.plugins.repository.query.expression.AndExpression; 061import org.ametys.plugins.repository.query.expression.Expression; 062import org.ametys.plugins.repository.query.expression.Expression.Operator; 063import org.ametys.plugins.repository.query.expression.StringExpression; 064import org.ametys.plugins.workflow.AbstractWorkflowComponent; 065import org.ametys.plugins.workflow.component.CheckRightsCondition; 066import org.ametys.runtime.plugin.component.AbstractLogEnabled; 067 068import com.opensymphony.workflow.WorkflowException; 069 070/** 071 * Copy updater to update the micro skills on a macro skill and the macro skills on a {@link Program} and (@link Course}. 072 */ 073public class SkillsCopyUpdater extends AbstractLogEnabled implements CopyCatalogUpdater, Serviceable 074{ 075 private static final List<String> __SKILLS_IGNORED_ATTRIBUTES = List.of("parentMacroSkill", "microSkills", "parentProgram", "catalog"); 076 077 /** The ametys object resolver */ 078 protected AmetysObjectResolver _resolver; 079 /** The observation manager */ 080 protected ObservationManager _observationManager; 081 /** The current user provider */ 082 protected CurrentUserProvider _currentUserProvider; 083 /** The content workflow helper */ 084 protected ContentWorkflowHelper _contentWorkflowHelper; 085 /** The ODF helper */ 086 protected ODFHelper _odfHelper; 087 088 public void service(ServiceManager manager) throws ServiceException 089 { 090 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 091 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 092 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 093 _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE); 094 _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE); 095 } 096 097 public void updateContents(String initialCatalogName, String newCatalogName, Map<Content, Content> copiedContents, Content targetParentContent) 098 { 099 // Do Nothing 100 } 101 102 public List<Content> getAdditionalContents(String catalogName) 103 { 104 List<Content> results = new ArrayList<>(); 105 106 results.addAll(_getContents(catalogName, SkillEditionFunction.MICRO_SKILL_TYPE)); 107 results.addAll(_getContents(catalogName, SkillEditionFunction.MACRO_SKILL_TYPE)); 108 109 return results; 110 } 111 112 public void copyAdditionalContents(String initialCatalogName, String newCatalogName, Map<Content, Content> copiedContents) 113 { 114 // Only copy the skills if they are enabled 115 if (!ODFSkillsHelper.isSkillsEnabled()) 116 { 117 return; 118 } 119 120 // Get the skills of the catalog to copy 121 List<DefaultContent> microSkillsToCopy = _getContents(initialCatalogName, SkillEditionFunction.MICRO_SKILL_TYPE); 122 List<DefaultContent> macroSkillsToCopy = _getContents(initialCatalogName, SkillEditionFunction.MACRO_SKILL_TYPE); 123 124 // Copy the micro skills in the new catalog 125 Map<String, Content> copiedMicroSkills = _copyMicroSkills(microSkillsToCopy, newCatalogName); 126 // Copy the macro skills in the new catalog and update the links to the copied micro skills 127 Map<String, Content> copiedMacroSkills = _copyMacroSkills(macroSkillsToCopy, newCatalogName, copiedMicroSkills); 128 129 // Update the links to the copied macro skills in programs and skills affectations in courses 130 _updateContentsAfterSkillsCreation(newCatalogName, copiedContents, copiedMacroSkills, copiedMicroSkills); 131 } 132 133 private Map<String, Content> _copyMacroSkills(List<DefaultContent> skills, String newCatalogName, Map<String, Content> copiedMicroSkills) 134 { 135 return _copySkills(skills, SkillEditionFunction.MACRO_SKILL_TYPE, newCatalogName, copiedMicroSkills); 136 } 137 138 private Map<String, Content> _copyMicroSkills(List<DefaultContent> skills, String newCatalogName) 139 { 140 return _copySkills(skills, SkillEditionFunction.MICRO_SKILL_TYPE, newCatalogName, Map.of()); 141 } 142 143 private Map<String, Content> _copySkills(List<DefaultContent> skills, String contentType, String newCatalogName, Map<String, Content> copiedMicroSkills) 144 { 145 Map<String, Content> copiedSkills = new HashMap<>(); 146 for (DefaultContent skill : skills) 147 { 148 // Log if the targeted catalog already contains the skill 149 if (_skillExists(skill, contentType, newCatalogName)) 150 { 151 getLogger().info("A skill already exists with the same code, catalog and language [{}, {}, {}]", skill.getValue("code"), newCatalogName, skill.getLanguage()); 152 } 153 // Copy the skill in the targeted catalog 154 else 155 { 156 try 157 { 158 ModifiableContent newSkill = _createSkill(skill, newCatalogName); 159 160 // If the skill is a macroSkill and has microSkills values, update the linkes to point to the new ones 161 if (contentType.equals(SkillEditionFunction.MACRO_SKILL_TYPE) && skill.hasValue("microSkills")) 162 { 163 // If the content is a macroSkill and has microSkills, link the new ones that where previously copied 164 Map<String, Object> values = new HashMap<>(); 165 ContentValue[] previousMicroSkills = skill.getValue("microSkills"); 166 List<Content> microSkills = Arrays.asList(previousMicroSkills) 167 .stream() 168 .filter(Objects::nonNull) 169 .map(microSkill -> copiedMicroSkills.get(microSkill.getContentId())) 170 .toList(); 171 172 values.put("microSkills", microSkills); 173 try 174 { 175 _contentWorkflowHelper.editContent((ModifiableDefaultContent) newSkill, values, 2); 176 } 177 catch (Exception e) 178 { 179 // Log and rollback 180 getLogger().error("Impossible to update skill '{}' ({}) while creating the catalog {}", newSkill.getTitle(), newSkill.getId(), newCatalogName); 181 182 _deleteContent(newSkill); 183 } 184 } 185 186 // If the skill could be created, add it to the copied skills 187 if (newSkill != null) 188 { 189 copiedSkills.put(skill.getId(), newSkill); 190 } 191 } 192 catch (AmetysRepositoryException e) 193 { 194 getLogger().error("Impossible to create the skill '{}' ({}) while creating the catalog {}", skill.getTitle(), skill.getId(), newCatalogName, e); 195 } 196 } 197 } 198 199 return copiedSkills; 200 } 201 202 private ModifiableContent _createSkill(DefaultContent skill, String newCatalogName) throws AmetysRepositoryException 203 { 204 // Create the skill in the new catalog 205 ModifiableContent newSkill = skill.copyTo((ModifiableTraversableAmetysObject) skill.getParent(), NameHelper.filterName(skill.getTitle())); 206 207 // Remove the attributes that need to be updated 208 for (String data : newSkill.getDataNames()) 209 { 210 // Remove the attribute that can't be copied 211 if (__SKILLS_IGNORED_ATTRIBUTES.contains(data)) 212 { 213 newSkill.removeValue(data); 214 } 215 } 216 // Set the new catalog 217 newSkill.setValue("catalog", newCatalogName); 218 newSkill.saveChanges(); 219 220 return newSkill; 221 } 222 223 private void _updateContentsAfterSkillsCreation(String newCatalogName, Map<Content, Content> copiedContents, Map<String, Content> copiedMacroSkills, Map<String, Content> copiedMicroSkills) 224 { 225 // For every copied program, update its links from the original macro skills to the copied macro skills 226 for (Content copiedContent : copiedContents.values()) 227 { 228 try 229 { 230 if (copiedContent instanceof Program program) 231 { 232 _updateProgram(program, copiedMacroSkills, copiedMicroSkills); 233 } 234 else if (copiedContent instanceof Course course) 235 { 236 _updateCourse(course, copiedMicroSkills); 237 } 238 } 239 catch (Exception e) 240 { 241 getLogger().error("An error occurred while copying the program '{}' in the new catalog '{}'", copiedContent.getId(), newCatalogName, e); 242 } 243 } 244 } 245 246 private void _updateProgram(Program program, Map<String, Content> copiedMacroSkills, Map<String, Content> copiedMicroSkills) throws WorkflowException 247 { 248 ContentValue[] programSkills = program.getValue(Program.SKILLS); 249 // Remove the skills without triggering the observer that would delete the skills 250 program.removeValue(Program.SKILLS); 251 program.saveChanges(); 252 253 // If the program has skills, update the links to target the copied macro skills 254 if (programSkills != null) 255 { 256 Content[] skills = _getCopiedSkills(programSkills, copiedMacroSkills); 257 258 for (Content skill : skills) 259 { 260 ModifiableContent skillModifiable = (ModifiableContent) skill; 261 // Set the parent program of the copied skills 262 skillModifiable.synchronizeValues(Map.of("parentProgram", program.getId())); 263 skillModifiable.saveChanges(); 264 265 // To update the version and indexation 266 _editContent(skillModifiable); 267 } 268 269 program.saveChanges(); 270 } 271 272 ContentValue[] programTransversalSkills = program.getValue(Program.TRANSVERSAL_SKILLS); 273 // If the program has transversal skills linked, update the link to target the copied macro skills 274 if (programTransversalSkills != null) 275 { 276 Content[] transversalSkills = _getCopiedSkills(programTransversalSkills, copiedMacroSkills); 277 program.setValue(Program.TRANSVERSAL_SKILLS, transversalSkills); 278 program.saveChanges(); 279 } 280 281 ContentValue[] programBlockingMicroSkills = program.getValue(Program.BLOCKING_SKILLS); 282 // If the program has blocking skills linked, update the link to target the copied micro skills 283 if (programBlockingMicroSkills != null) 284 { 285 Content[] blockingMicroSkills = _getCopiedSkills(programBlockingMicroSkills, copiedMicroSkills); 286 program.setValue(Program.BLOCKING_SKILLS, blockingMicroSkills); 287 program.saveChanges(); 288 } 289 } 290 291 private void _updateCourse(Course course, Map<String, Content> copiedMicroSkills) 292 { 293 String catalog = course.getCatalog(); 294 295 // Get the micro skills of the course by program 296 List<? extends ModifiableModelAwareRepeaterEntry> microSkillsByProgramEntries = Optional.ofNullable(course.getRepeater(Course.ACQUIRED_MICRO_SKILLS)) 297 .map(ModifiableModelAwareRepeater::getEntries) 298 .orElse(List.of()); 299 300 for (ModifiableModelAwareRepeaterEntry entry : microSkillsByProgramEntries) 301 { 302 ContentValue[] microSkills = entry.getValue(Course.ACQUIRED_MICRO_SKILLS_SKILLS, false, new ContentValue[0]); 303 Content[] newMicroSkills = _getCopiedSkills(microSkills, copiedMicroSkills); 304 entry.setValue(Course.ACQUIRED_MICRO_SKILLS_SKILLS, newMicroSkills); 305 306 String programId = Optional.ofNullable(entry.<ContentValue>getValue(Course.ACQUIRED_MICRO_SKILLS_PROGRAM)) 307 .flatMap(ContentValue::getContentIfExists) 308 .map(Program.class::cast) 309 .map(p -> _getCopiedProgram(p, catalog)) 310 .map(Content::getId) 311 .orElse(null); 312 entry.setValue(Course.ACQUIRED_MICRO_SKILLS_PROGRAM, programId); 313 } 314 course.saveChanges(); 315 } 316 317 private Program _getCopiedProgram(Program program, String newCatalog) 318 { 319 return _odfHelper.getODFContent(ProgramFactory.PROGRAM_CONTENT_TYPE, program.getCode(), newCatalog, program.getLanguage()); 320 } 321 322 private Content[] _getCopiedSkills(ContentValue[] originalSkills, Map<String, Content> copiedSkills) 323 { 324 return Arrays.asList(originalSkills) 325 .stream() 326 // Keep the former skill if the copy is not found 327 .map(originalSkill -> copiedSkills.getOrDefault(originalSkill.getContentId(), originalSkill.getContent())) 328 .toArray(Content[]::new); 329 } 330 331 private boolean _skillExists(Content skill, String contentType, String newCatalogName) 332 { 333 return _getContents(newCatalogName, contentType, skill).findAny().isPresent(); 334 } 335 336 private <T extends Content> List<T> _getContents(String catalogName, String contentType) 337 { 338 return this.<T>_getContents(catalogName, contentType, null).toList(); 339 } 340 341 private <T extends Content> Stream<T> _getContents(String catalogName, String contentType, Content skill) 342 { 343 List<Expression> exprs = new ArrayList<>(); 344 exprs.add(new ContentTypeExpression(Operator.EQ, contentType)); 345 exprs.add(new StringExpression("catalog", Operator.EQ, catalogName)); 346 if (skill != null) 347 { 348 exprs.add(new StringExpression("code", Operator.EQ, skill.getValue("code"))); 349 } 350 Expression expression = new AndExpression(exprs.toArray(Expression[]::new)); 351 352 String query = ContentQueryHelper.getContentXPathQuery(expression); 353 return _resolver.<T>query(query).stream(); 354 } 355 356 private void _deleteContent(Content content) 357 { 358 Map<String, Object> eventParams = new HashMap<>(); 359 eventParams.put(ObservationConstants.ARGS_CONTENT, content); 360 eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName()); 361 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId()); 362 ModifiableAmetysObject parent = content.getParent(); 363 364 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParams)); 365 366 // Remove the content. 367 LockableAmetysObject lockedContent = (LockableAmetysObject) content; 368 if (lockedContent.isLocked()) 369 { 370 lockedContent.unlock(); 371 } 372 373 ((RemovableAmetysObject) content).remove(); 374 375 parent.saveChanges(); 376 377 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams)); 378 } 379 380 private void _editContent(Content content) throws AmetysRepositoryException, WorkflowException 381 { 382 Map<String, Object> parameters = new HashMap<>(); 383 parameters.put(EditContentFunction.QUIT, true); 384 385 Map<String, Object> inputs = new HashMap<>(); 386 inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, parameters); 387 // Force because we don't want to control user rights with this operation 388 inputs.put(CheckRightsCondition.FORCE, true); 389 390 _contentWorkflowHelper.doAction((WorkflowAwareContent) content, 222, inputs); 391 } 392}