001/* 002 * Copyright 2014 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.time.Duration; 019import java.time.ZonedDateTime; 020import java.time.temporal.ChronoUnit; 021import java.util.ArrayList; 022import java.util.Collection; 023import java.util.HashMap; 024import java.util.List; 025import java.util.Map; 026import java.util.Optional; 027import java.util.stream.Collectors; 028import java.util.stream.Stream; 029 030import org.apache.avalon.framework.component.Component; 031import org.apache.avalon.framework.context.Context; 032import org.apache.avalon.framework.context.ContextException; 033import org.apache.avalon.framework.context.Contextualizable; 034import org.apache.avalon.framework.service.ServiceException; 035import org.apache.avalon.framework.service.ServiceManager; 036import org.apache.avalon.framework.service.Serviceable; 037import org.apache.cocoon.ProcessingException; 038import org.apache.cocoon.components.ContextHelper; 039import org.apache.cocoon.environment.Request; 040import org.apache.commons.lang.StringUtils; 041 042import org.ametys.cms.ObservationConstants; 043import org.ametys.cms.content.archive.ArchiveConstants; 044import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 045import org.ametys.cms.data.ContentValue; 046import org.ametys.cms.indexing.solr.SolrIndexHelper; 047import org.ametys.cms.repository.Content; 048import org.ametys.cms.repository.ContentQueryHelper; 049import org.ametys.cms.repository.ContentTypeExpression; 050import org.ametys.cms.repository.LanguageExpression; 051import org.ametys.cms.repository.ModifiableDefaultContent; 052import org.ametys.cms.repository.WorkflowAwareContent; 053import org.ametys.cms.rights.ContentRightAssignmentContext; 054import org.ametys.cms.workflow.ContentWorkflowHelper; 055import org.ametys.core.observation.Event; 056import org.ametys.core.observation.ObservationManager; 057import org.ametys.core.schedule.progression.ContainerProgressionTracker; 058import org.ametys.core.schedule.progression.SimpleProgressionTracker; 059import org.ametys.core.ui.Callable; 060import org.ametys.core.user.CurrentUserProvider; 061import org.ametys.odf.ODFHelper; 062import org.ametys.odf.ProgramItem; 063import org.ametys.odf.course.Course; 064import org.ametys.odf.course.CourseContainer; 065import org.ametys.odf.courselist.CourseList; 066import org.ametys.odf.courselist.CourseListContainer; 067import org.ametys.odf.coursepart.CoursePart; 068import org.ametys.odf.coursepart.CoursePartFactory; 069import org.ametys.odf.data.EducationalPath; 070import org.ametys.odf.program.Program; 071import org.ametys.odf.program.ProgramFactory; 072import org.ametys.odf.program.TraversableProgramPart; 073import org.ametys.plugins.repository.AmetysObject; 074import org.ametys.plugins.repository.AmetysObjectIterable; 075import org.ametys.plugins.repository.AmetysObjectResolver; 076import org.ametys.plugins.repository.AmetysRepositoryException; 077import org.ametys.plugins.repository.ModifiableAmetysObject; 078import org.ametys.plugins.repository.ModifiableTraversableAmetysObject; 079import org.ametys.plugins.repository.RemovableAmetysObject; 080import org.ametys.plugins.repository.RepositoryConstants; 081import org.ametys.plugins.repository.TraversableAmetysObject; 082import org.ametys.plugins.repository.UnknownAmetysObjectException; 083import org.ametys.plugins.repository.lock.LockableAmetysObject; 084import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector; 085import org.ametys.plugins.repository.query.QueryHelper; 086import org.ametys.plugins.repository.query.SortCriteria; 087import org.ametys.plugins.repository.query.expression.AndExpression; 088import org.ametys.plugins.repository.query.expression.Expression.Operator; 089import org.ametys.plugins.repository.query.expression.StringExpression; 090import org.ametys.runtime.i18n.I18nizableText; 091import org.ametys.runtime.plugin.component.AbstractLogEnabled; 092import org.ametys.runtime.plugin.component.PluginAware; 093 094import com.opensymphony.workflow.WorkflowException; 095 096/** 097 * Component to handle ODF catalogs 098 */ 099public class CatalogsManager extends AbstractLogEnabled implements Serviceable, Component, PluginAware, Contextualizable 100{ 101 /** Avalon Role */ 102 public static final String ROLE = CatalogsManager.class.getName(); 103 104 private AmetysObjectResolver _resolver; 105 106 private CopyCatalogUpdaterExtensionPoint _copyUpdaterEP; 107 108 private ObservationManager _observationManager; 109 110 private CurrentUserProvider _userProvider; 111 112 private ContentWorkflowHelper _contentWorkflowHelper; 113 114 private String _pluginName; 115 116 private ODFHelper _odfHelper; 117 118 private ContentTypeExtensionPoint _cTypeEP; 119 120 private Context _context; 121 122 private SolrIndexHelper _solrIndexHelper; 123 124 private String _defaultCatalogId; 125 126 private CurrentUserProvider _currentUserProvider; 127 128 public void contextualize(Context context) throws ContextException 129 { 130 _context = context; 131 } 132 133 @Override 134 public void service(ServiceManager manager) throws ServiceException 135 { 136 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 137 _copyUpdaterEP = (CopyCatalogUpdaterExtensionPoint) manager.lookup(CopyCatalogUpdaterExtensionPoint.ROLE); 138 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 139 _userProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 140 _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE); 141 _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE); 142 _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 143 _solrIndexHelper = (SolrIndexHelper) manager.lookup(SolrIndexHelper.ROLE); 144 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 145 } 146 147 public void setPluginInfo(String pluginName, String featureName, String id) 148 { 149 _pluginName = pluginName; 150 } 151 152 /** 153 * Get the list of catalogs 154 * @return the catalogs 155 */ 156 public List<Catalog> getCatalogs() 157 { 158 List<Catalog> result = new ArrayList<>(); 159 160 TraversableAmetysObject catalogsNode = getCatalogsRootNode(); 161 162 AmetysObjectIterable<Catalog> catalogs = catalogsNode.getChildren(); 163 for (Catalog catalog : catalogs) 164 { 165 result.add(catalog); 166 } 167 168 return result; 169 } 170 171 /** 172 * Get a catalog matching with the given name 173 * @param name The name 174 * @return a catalog, or null if not found 175 */ 176 public Catalog getCatalog(String name) 177 { 178 ModifiableTraversableAmetysObject catalogsNode = getCatalogsRootNode(); 179 180 if (StringUtils.isNotEmpty(name) && catalogsNode.hasChild(name)) 181 { 182 return catalogsNode.getChild(name); 183 } 184 185 // Not found 186 return null; 187 } 188 189 /** 190 * Returns the name of the default catalog 191 * @return the name of the default catalog 192 */ 193 @Callable (rights = Callable.NO_CHECK_REQUIRED) 194 public String getDefaultCatalogName() 195 { 196 return Optional.ofNullable(getDefaultCatalog()) 197 .map(Catalog::getName) 198 .orElse(null); 199 } 200 201 /** 202 * Returns the default catalog 203 * @return the default catalog or null if no default catalog was defined. 204 */ 205 public synchronized Catalog getDefaultCatalog() 206 { 207 if (_defaultCatalogId == null) 208 { 209 updateDefaultCatalog(); 210 } 211 212 if (_defaultCatalogId != null) 213 { 214 try 215 { 216 return _resolver.resolveById(_defaultCatalogId); 217 } 218 catch (UnknownAmetysObjectException e) 219 { 220 _defaultCatalogId = null; 221 } 222 } 223 224 return null; 225 } 226 227 /** 228 * Updates the default catalog (if it's null or if the user has updated it). 229 */ 230 void updateDefaultCatalog() 231 { 232 List<Catalog> catalogs = getCatalogs(); 233 for (Catalog catalog : catalogs) 234 { 235 if (catalog.isDefault()) 236 { 237 _defaultCatalogId = catalog.getId(); 238 return; 239 } 240 } 241 242 // If no default catalog found, get the only catalog if it exists 243 if (catalogs.size() == 1) 244 { 245 _defaultCatalogId = catalogs.get(0).getId(); 246 } 247 } 248 249 /** 250 * Get the name of the catalog of a ODF content 251 * @param contentId The id of content 252 * @return The catalog's name 253 */ 254 @Callable (rights = Callable.READ_ACCESS, paramIndex = 0, rightContext = ContentRightAssignmentContext.ID) 255 public String getContentCatalog(String contentId) 256 { 257 Content content = _resolver.resolveById(contentId); 258 259 if (content instanceof ProgramItem) 260 { 261 return ((ProgramItem) content).getCatalog(); 262 } 263 264 // catalog can also be present on skills 265 // FIXME ODF-3966 - With a new mixin for catalog aware contents, we could have same kind of API that the one existing on ProgramItem 266 if (content.hasValue("catalog")) 267 { 268 return content.getValue("catalog"); 269 } 270 271 // Get catalog from its parents (unecessary ?) 272 AmetysObject parent = content.getParent(); 273 while (parent != null) 274 { 275 if (parent instanceof ProgramItem) 276 { 277 return ((ProgramItem) parent).getCatalog(); 278 } 279 parent = parent.getParent(); 280 } 281 282 return null; 283 } 284 285 /** 286 * Determines if the catalog can be modified from the given content 287 * @param contentId The content id 288 * @return A map with success=false if the catalog cannot be edited 289 */ 290 @Callable (rights = "ODF_Rights_EditCatalog", paramIndex = 0, rightContext = ContentRightAssignmentContext.ID) 291 public Map<String, Object> canEditCatalog(String contentId) 292 { 293 Map<String, Object> result = new HashMap<>(); 294 295 Content content = _resolver.resolveById(contentId); 296 297 if (content instanceof ProgramItem) 298 { 299 if (_isReferenced(content)) 300 { 301 result.put("success", false); 302 result.put("error", "referenced"); 303 } 304 else if (_hasSharedContent((ProgramItem) content, (ProgramItem) content)) 305 { 306 result.put("success", false); 307 result.put("error", "hasSharedContent"); 308 } 309 else 310 { 311 result.put("success", true); 312 } 313 314 } 315 else 316 { 317 result.put("success", false); 318 result.put("error", "typeError"); 319 } 320 321 return result; 322 } 323 324 private boolean _isReferenced (Content content) 325 { 326 return !_odfHelper.getParentProgramItems((ProgramItem) content).isEmpty(); 327 } 328 329 private boolean _hasSharedContent (ProgramItem rootProgramItem, ProgramItem programItem) 330 { 331 List<ProgramItem> children = _odfHelper.getChildProgramItems(programItem); 332 333 for (ProgramItem child : children) 334 { 335 if (_isShared(rootProgramItem, child)) 336 { 337 return true; 338 } 339 } 340 341 if (programItem instanceof Course) 342 { 343 List<CoursePart> courseParts = ((Course) programItem).getCourseParts(); 344 for (CoursePart coursePart : courseParts) 345 { 346 List<ProgramItem> parentCourses = coursePart.getCourses() 347 .stream() 348 .map(ProgramItem.class::cast) 349 .collect(Collectors.toList()); 350 if (parentCourses.size() > 1 && !_isPartOfSameStructure(rootProgramItem, parentCourses)) 351 { 352 return true; 353 } 354 } 355 } 356 357 return false; 358 } 359 360 private boolean _isShared(ProgramItem rootProgramItem, ProgramItem programItem) 361 { 362 try 363 { 364 List<ProgramItem> parents = _odfHelper.getParentProgramItems(programItem); 365 if (parents.size() > 1 && !_isPartOfSameStructure(rootProgramItem, parents) 366 || _hasSharedContent(rootProgramItem, programItem)) 367 { 368 return true; 369 } 370 } 371 catch (UnknownAmetysObjectException e) 372 { 373 // Nothing 374 } 375 376 return false; 377 } 378 379 private boolean _isPartOfSameStructure(ProgramItem rootProgramItem, List<ProgramItem> programItems) 380 { 381 for (ProgramItem programItem : programItems) 382 { 383 boolean isPartOfInitalStructure = false; 384 385 List<EducationalPath> ancestorPaths = _odfHelper.getEducationalPaths(programItem); 386 387 for (EducationalPath ancestorPath : ancestorPaths) 388 { 389 isPartOfInitalStructure = ancestorPath.resolveProgramItems(_resolver).anyMatch(p -> p.equals(rootProgramItem)); 390 break; 391 } 392 393 if (!isPartOfInitalStructure) 394 { 395 // The content is shared outside the program item to edit 396 return false; 397 } 398 } 399 400 return true; 401 } 402 403 /** 404 * Set the catalog of a content. This will modify recursively the catalog of referenced children 405 * @param catalog The catalog 406 * @param contentId The id of content to edit 407 * @throws WorkflowException if an error occurred 408 */ 409 @Callable (rights = "ODF_Rights_EditCatalog", paramIndex = 1, rightContext = ContentRightAssignmentContext.ID) 410 public void setContentCatalog(String catalog, String contentId) throws WorkflowException 411 { 412 Content content = _resolver.resolveById(contentId); 413 414 if (content instanceof ProgramItem) 415 { 416 _setCatalog(content, catalog); 417 } 418 else 419 { 420 throw new IllegalArgumentException("You can not edit the catalog of the content " + contentId); 421 } 422 } 423 424 private void _setCatalog (Content content, String catalogName) throws WorkflowException 425 { 426 if (content instanceof ProgramItem) 427 { 428 String oldCatalog = ((ProgramItem) content).getCatalog(); 429 if (!catalogName.equals(oldCatalog)) 430 { 431 ((ProgramItem) content).setCatalog(catalogName); 432 433 if (content instanceof WorkflowAwareContent) 434 { 435 _applyChanges((WorkflowAwareContent) content); 436 } 437 } 438 } 439 else if (content instanceof CoursePart) 440 { 441 String oldCatalog = ((CoursePart) content).getCatalog(); 442 if (!catalogName.equals(oldCatalog)) 443 { 444 ((CoursePart) content).setCatalog(catalogName); 445 446 if (content instanceof WorkflowAwareContent) 447 { 448 _applyChanges((WorkflowAwareContent) content); 449 } 450 } 451 } 452 453 _setCatalogToChildren(content, catalogName); 454 } 455 456 private void _setCatalogToChildren (Content content, String catalogName) throws WorkflowException 457 { 458 if (content instanceof TraversableProgramPart) 459 { 460 ContentValue[] children = content.getValue(TraversableProgramPart.CHILD_PROGRAM_PARTS, false, new ContentValue[0]); 461 for (ContentValue child : children) 462 { 463 try 464 { 465 _setCatalog(child.getContent(), catalogName); 466 } 467 catch (UnknownAmetysObjectException e) 468 { 469 // Nothing 470 } 471 } 472 } 473 474 if (content instanceof CourseContainer) 475 { 476 for (Course course : ((CourseContainer) content).getCourses()) 477 { 478 _setCatalog(course, catalogName); 479 } 480 } 481 482 if (content instanceof CourseListContainer) 483 { 484 for (CourseList cl : ((CourseListContainer) content).getCourseLists()) 485 { 486 _setCatalog(cl, catalogName); 487 } 488 } 489 490 if (content instanceof Course) 491 { 492 for (CoursePart coursePart : ((Course) content).getCourseParts()) 493 { 494 _setCatalog(coursePart, catalogName); 495 } 496 } 497 } 498 499 private void _applyChanges(WorkflowAwareContent content) throws WorkflowException 500 { 501 ((ModifiableDefaultContent) content).setLastContributor(_userProvider.getUser()); 502 ((ModifiableDefaultContent) content).setLastModified(ZonedDateTime.now()); 503 504 // Remove the proposal date. 505 content.setProposalDate(null); 506 507 // Save changes 508 content.saveChanges(); 509 510 // Notify listeners 511 Map<String, Object> eventParams = new HashMap<>(); 512 eventParams.put(ObservationConstants.ARGS_CONTENT, content); 513 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId()); 514 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _userProvider.getUser(), eventParams)); 515 516 _contentWorkflowHelper.doAction(content, 22); 517 } 518 519 /** 520 * Get the root catalogs storage object. 521 * @return the root catalogs node 522 * @throws AmetysRepositoryException if a repository error occurs. 523 */ 524 public ModifiableTraversableAmetysObject getCatalogsRootNode() throws AmetysRepositoryException 525 { 526 String originalWorkspace = null; 527 Request request = ContextHelper.getRequest(_context); 528 try 529 { 530 originalWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request); 531 if (ArchiveConstants.ARCHIVE_WORKSPACE.equals(originalWorkspace)) 532 { 533 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, RepositoryConstants.DEFAULT_WORKSPACE); 534 } 535 536 ModifiableTraversableAmetysObject rootNode = _resolver.resolveByPath("/"); 537 ModifiableTraversableAmetysObject pluginsNode = _getOrCreateNode(rootNode, "ametys:plugins", "ametys:unstructured"); 538 ModifiableTraversableAmetysObject pluginNode = _getOrCreateNode(pluginsNode, _pluginName, "ametys:unstructured"); 539 540 return _getOrCreateNode(pluginNode, "catalogs", "ametys:unstructured"); 541 } 542 catch (AmetysRepositoryException e) 543 { 544 throw new AmetysRepositoryException("Unable to get the ODF catalogs root node", e); 545 } 546 finally 547 { 548 if (ArchiveConstants.ARCHIVE_WORKSPACE.equals(originalWorkspace)) 549 { 550 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, originalWorkspace); 551 } 552 } 553 } 554 555 /** 556 * Create a new catalog 557 * @param name The unique name 558 * @param title The title of catalog 559 * @return the created catalog 560 */ 561 public Catalog createCatalog(String name, String title) 562 { 563 Catalog newCatalog = null; 564 565 ModifiableTraversableAmetysObject catalogsNode = getCatalogsRootNode(); 566 567 newCatalog = catalogsNode.createChild(name, "ametys:catalog"); 568 newCatalog.setTitle(title); 569 570 if (getCatalogs().size() == 1) 571 { 572 newCatalog.setDefault(true); 573 } 574 575 newCatalog.saveChanges(); 576 577 return newCatalog; 578 } 579 580 /** 581 * Get the programs of a catalog for all languages 582 * @param catalog The code of catalog 583 * @return The programs 584 */ 585 public AmetysObjectIterable<Program> getPrograms (String catalog) 586 { 587 return getPrograms(catalog, null); 588 } 589 590 /** 591 * Get the program's items of a catalog for all languages 592 * @param catalog The code of catalog 593 * @return The {@link ProgramItem} 594 */ 595 private AmetysObjectIterable<Content> _getProgramItems(String catalog) 596 { 597 return _getContentsInCatalog(catalog, ProgramItem.PROGRAM_ITEM_CONTENT_TYPE); 598 } 599 600 /** 601 * Get the contents of a content type in a catalog. 602 * @param <T> The type of the elements to get 603 * @param catalog The catalog name 604 * @param contentTypeId The content type identifier 605 * @return An iterable of contents with given content type in the catalog 606 */ 607 private <T extends Content> AmetysObjectIterable<T> _getContentsInCatalog(String catalog, String contentTypeId) 608 { 609 AndExpression expression = new AndExpression(); 610 expression.add(_cTypeEP.createHierarchicalCTExpression(contentTypeId)); 611 expression.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog)); 612 613 String query = ContentQueryHelper.getContentXPathQuery(expression); 614 return _resolver.query(query); 615 } 616 617 /** 618 * Get the programs of a catalog 619 * @param catalog The code of catalog 620 * @param lang The language. Can be null to get programs for all languages 621 * @return The programs 622 */ 623 public AmetysObjectIterable<Program> getPrograms (String catalog, String lang) 624 { 625 AndExpression expression = new AndExpression(); 626 expression.add(new ContentTypeExpression(Operator.EQ, ProgramFactory.PROGRAM_CONTENT_TYPE)); 627 expression.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog)); 628 if (lang != null) 629 { 630 expression.add(new LanguageExpression(Operator.EQ, lang)); 631 } 632 633 // Add sort criteria to get size 634 SortCriteria sortCriteria = new SortCriteria(); 635 sortCriteria.addCriterion(Content.ATTRIBUTE_TITLE, true, true); 636 637 String programsQuery = QueryHelper.getXPathQuery(null, ProgramFactory.PROGRAM_NODETYPE, expression, sortCriteria); 638 return _resolver.query(programsQuery); 639 } 640 641 /** 642 * Copy the programs and its hierarchy from a catalog to another. 643 * The referenced courses are NOT copied. 644 * @param catalog The new catalog to populate 645 * @param catalogToCopy The catalog from which we copy the programs. 646 * @param progressionTracker the progression tracker for catalog copy 647 * @throws ProcessingException If an error occurred during copy 648 */ 649 public void copyCatalog(Catalog catalog, Catalog catalogToCopy, ContainerProgressionTracker progressionTracker) throws ProcessingException 650 { 651 progressionTracker.addSimpleStep("copy-step", new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_COPY_CATALOG_COPY_STEP_LABEL"), 100); 652 progressionTracker.addSimpleStep("additional-copy-step", new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_COPY_CATALOG_ADDITIONAL_COPY_STEP_LABEL"), 10); 653 progressionTracker.addSimpleStep("updates-after-copy-step", new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_COPY_CATALOG_UPDATE_AFTER_COPY_STEP_LABEL"), 5); 654 progressionTracker.addSimpleStep("update-workflow-step", new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_COPY_CATALOG_UPDATE_WORKFLOW_STEP_LABEL"), 10); 655 progressionTracker.addSimpleStep("restart-solr-commit", new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_COPY_CATALOG_RESTART_SOLR_COMMIT_STEP_LABEL"), 10); 656 657 String catalogToCopyName = catalogToCopy.getName(); 658 String catalogName = catalog.getName(); 659 String [] handledEventIds = new String[] {ObservationConstants.EVENT_CONTENT_ADDED, ObservationConstants.EVENT_CONTENT_MODIFIED, ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED}; 660 try 661 { 662 List<CopyCatalogUpdater> copyUpdaters = _copyUpdaterEP.getExtensionsIds() 663 .stream() 664 .map(_copyUpdaterEP::getExtension) 665 .toList(); 666 667 // Do NOT commit yet to Solr in order to improve perfs 668 _solrIndexHelper.pauseSolrCommitForEvents(handledEventIds); 669 long start = System.currentTimeMillis(); 670 671 // Copy all the catalog's contents: programs and their children, and side contents associated to a catalog like thematics or skills 672 Map<Content, Content> copiedContents = new HashMap<>(); 673 copiedContents.putAll(_copyPrograms(catalogToCopyName, catalogName, (SimpleProgressionTracker) progressionTracker.getCurrentStep())); 674 copiedContents.putAll(_copyAdditionalContents(copyUpdaters, catalogToCopyName, catalogName, (SimpleProgressionTracker) progressionTracker.getCurrentStep())); 675 676 // Update the references into the copied contents or remove data should not be copied 677 _updateContents(copyUpdaters, copiedContents, catalogToCopyName, catalogName, (SimpleProgressionTracker) progressionTracker.getCurrentStep()); 678 679 // Add workflow step to each contents 680 _addCopyWorkflowStep(copiedContents.values(), (SimpleProgressionTracker) progressionTracker.getCurrentStep()); 681 682 if (getLogger().isDebugEnabled()) 683 { 684 getLogger().debug("End of iteration over programs for copying them ({})", Duration.of((System.currentTimeMillis() - start) / 1000, ChronoUnit.SECONDS)); 685 } 686 687 } 688 catch (AmetysRepositoryException | WorkflowException e) 689 { 690 getLogger().error("Copy of items of catalog {} into catalog {} has failed", catalogToCopyName, catalogName); 691 throw new ProcessingException("Failed to copy catalog", e); 692 } 693 finally 694 { 695 SimpleProgressionTracker restartCommitStep = (SimpleProgressionTracker) progressionTracker.getCurrentStep(); 696 restartCommitStep.setSize(1); 697 _solrIndexHelper.restartSolrCommitForEvents(handledEventIds); 698 restartCommitStep.increment(); 699 } 700 } 701 702 private Map<Content, Content> _copyPrograms(String catalogToCopyName, String catalogName, SimpleProgressionTracker progressionTracker) throws AmetysRepositoryException, WorkflowException 703 { 704 Map<Content, Content> copiedContents = new HashMap<>(); 705 706 AmetysObjectIterable<Program> programs = getPrograms(catalogToCopyName); 707 708 progressionTracker.setSize(programs.getSize()); 709 710 getLogger().debug("Begin to iterate over programs for copying them"); 711 712 for (Program program : programs) 713 { 714 if (getLogger().isDebugEnabled()) 715 { 716 getLogger().debug("Start copying program '{}' (name: '{}', title: '{}')...", program.getId(), program.getName(), program.getTitle()); 717 } 718 719 _odfHelper.copyProgramItem(program, catalogName, true, copiedContents); 720 progressionTracker.increment(); 721 } 722 723 return copiedContents; 724 } 725 726 private Map<Content, Content> _copyAdditionalContents(Collection<CopyCatalogUpdater> copyUpdaters, String catalogToCopyName, String catalogName, SimpleProgressionTracker progressionTracker) throws AmetysRepositoryException 727 { 728 Map<Content, Content> copiedContents = new HashMap<>(); 729 730 progressionTracker.setSize(copyUpdaters.size()); 731 732 for (CopyCatalogUpdater updater : copyUpdaters) 733 { 734 // Call additional contents to copy (thematics, skills, etc.) 735 copiedContents.putAll(updater.copyAdditionalContents(catalogToCopyName, catalogName)); 736 737 progressionTracker.increment(); 738 } 739 740 return copiedContents; 741 } 742 743 private void _updateContents(Collection<CopyCatalogUpdater> copyUpdaters, Map<Content, Content> copiedContents, String catalogToCopyName, String catalogName, SimpleProgressionTracker progressionTracker) throws AmetysRepositoryException 744 { 745 progressionTracker.setSize(copyUpdaters.size()); 746 747 for (CopyCatalogUpdater updater : copyUpdaters) 748 { 749 // Call additional contents to copy (thematics, skills, etc.) 750 updater.updateContents(catalogToCopyName, catalogName, copiedContents, null); 751 752 progressionTracker.increment(); 753 } 754 } 755 756 private void _addCopyWorkflowStep(Collection<Content> contents, SimpleProgressionTracker progressionTracker) throws AmetysRepositoryException, WorkflowException 757 { 758 progressionTracker.setSize(contents.size()); 759 760 for (Content content : contents) 761 { 762 if (content instanceof WorkflowAwareContent workflowAwareContent) 763 { 764 _contentWorkflowHelper.doAction(workflowAwareContent, getCopyActionId(content)); 765 } 766 progressionTracker.increment(); 767 } 768 } 769 770 /** 771 * Get the workflow action id for copy. 772 * @param content The content concerned by the copy 773 * @return The workflow action id 774 */ 775 protected int getCopyActionId(Content content) 776 { 777 if (content instanceof ProgramItem || content instanceof CoursePart) 778 { 779 return 210; 780 } 781 return 222; 782 } 783 784 /** 785 * Delete catalog 786 * @param catalog the catalog to delete 787 * @return the result map 788 */ 789 public Map<String, Object> deleteCatalog(Catalog catalog) 790 { 791 Map<String, Object> result = new HashMap<>(); 792 result.put("id", catalog.getId()); 793 794 String catalogName = catalog.getName(); 795 List<Content> contentsToDelete = getContents(catalogName); 796 797 // Before deleting anything, we have to make sure that it's safe to delete the catalog and its programItems 798 List<Content> referencingContents = _getExternalReferencingContents(contentsToDelete); 799 800 if (!referencingContents.isEmpty()) 801 { 802 for (Content content : referencingContents) 803 { 804 if (content instanceof ProgramItem || content instanceof CoursePart) 805 { 806 getLogger().error("{} '{}' ({}) is referencing a content of the catalog {} while being itself in the catalog {}. There is an inconsistency.", 807 content.getClass().getName(), 808 content.getTitle(), 809 content.getValue("code"), 810 catalogName, 811 content.getValue("catalog", false, StringUtils.EMPTY)); 812 } 813 else 814 { 815 getLogger().warn("Content {} ({}) is referencing a content of the catalog {}. There is an inconsistency.", 816 content.getTitle(), 817 content.getId(), 818 catalogName); 819 } 820 } 821 result.put("error", "referencing-contents"); 822 result.put("referencingContents", referencingContents); 823 return result; 824 } 825 826 // Everything is fine, we can delete the courseParts, the programItems and the catalog 827 String[] handledEventIds = new String[] {ObservationConstants.EVENT_CONTENT_DELETED}; 828 try 829 { 830 _solrIndexHelper.pauseSolrCommitForEvents(handledEventIds); 831 contentsToDelete.forEach(this::_deleteContent); 832 } 833 finally 834 { 835 _solrIndexHelper.restartSolrCommitForEvents(handledEventIds); 836 } 837 838 ModifiableAmetysObject parent = catalog.getParent(); 839 catalog.remove(); 840 parent.saveChanges(); 841 842 return result; 843 } 844 845 /** 846 * Get all the contents to delete when deleting a catalog. 847 * @param catalogName The catalog name 848 * @return a {@link Stream} of {@link Content} to delete 849 */ 850 public List<Content> getContents(String catalogName) 851 { 852 List<Content> contents = new ArrayList<>(); 853 contents.addAll(_getProgramItems(catalogName).stream().toList()); 854 contents.addAll(_getCourseParts(catalogName).stream().toList()); 855 856 for (String updaterId : _copyUpdaterEP.getExtensionsIds()) 857 { 858 CopyCatalogUpdater updater = _copyUpdaterEP.getExtension(updaterId); 859 contents.addAll(updater.getAdditionalContents(catalogName)); 860 } 861 862 return contents; 863 } 864 865 /** 866 * Get the course part of a catalog for all languages 867 * @param catalog The code of catalog 868 * @return The {@link CoursePart}s 869 */ 870 private AmetysObjectIterable<CoursePart> _getCourseParts(String catalog) 871 { 872 return _getContentsInCatalog(catalog, CoursePartFactory.COURSE_PART_CONTENT_TYPE); 873 } 874 875 /** 876 * Get the list of contents referencing one of the {@code ProgramItem}s in the set. 877 * This method will ignore content that are included in the set and any {@code CoursePart} belonging to a {@code Course} of the set 878 * @param contentsToDelete the contents to be tested 879 * @return the contents referencing those program items excluding the course parts id 880 */ 881 private List<Content> _getExternalReferencingContents(List<Content> contentsToDelete) 882 { 883 884 // Get all the Contents referencing one of the content to delete but not one of the content to delete 885 List<Content> referencingContents = contentsToDelete.stream() 886 .map(Content::getReferencingContents) // get the referencing contents 887 .flatMap(Collection::stream) // flatten the Collection 888 .distinct() // remove all duplicates 889 .filter(content -> !contentsToDelete.contains(content)) 890 .collect(Collectors.toUnmodifiableList()); // collect it into a list 891 return referencingContents; 892 } 893 894 private void _deleteContent(Content deletedContent) 895 { 896 try 897 { 898 RemovableAmetysObject content = _resolver.resolveById(deletedContent.getId()); 899 900 Map<String, Object> eventParams = new HashMap<>(); 901 eventParams.put(ObservationConstants.ARGS_CONTENT, content); 902 eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName()); 903 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId()); 904 ModifiableAmetysObject parent = content.getParent(); 905 906 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParams)); 907 908 // Remove the content. 909 LockableAmetysObject lockedContent = (LockableAmetysObject) content; 910 if (lockedContent.isLocked()) 911 { 912 lockedContent.unlock(); 913 } 914 915 content.remove(); 916 917 parent.saveChanges(); 918 919 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams)); 920 } 921 catch (UnknownAmetysObjectException e) 922 { 923 // Ignore, already removed 924 if (getLogger().isDebugEnabled()) 925 { 926 getLogger().debug("An error occured while trying to delete the content '{}' while deleting the catalog.", deletedContent.getId(), e); 927 } 928 } 929 } 930 931 private ModifiableTraversableAmetysObject _getOrCreateNode(ModifiableTraversableAmetysObject parentNode, String nodeName, String nodeType) throws AmetysRepositoryException 932 { 933 ModifiableTraversableAmetysObject definitionsNode; 934 if (parentNode.hasChild(nodeName)) 935 { 936 definitionsNode = parentNode.getChild(nodeName); 937 } 938 else 939 { 940 definitionsNode = parentNode.createChild(nodeName, nodeType); 941 parentNode.saveChanges(); 942 } 943 return definitionsNode; 944 } 945}