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