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.io.IOException; 019import java.time.Duration; 020import java.time.temporal.ChronoUnit; 021import java.util.ArrayList; 022import java.util.Collection; 023import java.util.Date; 024import java.util.HashMap; 025import java.util.List; 026import java.util.Map; 027import java.util.Set; 028import java.util.concurrent.ExecutionException; 029import java.util.concurrent.Future; 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; 042import org.apache.solr.client.solrj.SolrServerException; 043 044import org.ametys.cms.ObservationConstants; 045import org.ametys.cms.content.archive.ArchiveConstants; 046import org.ametys.cms.content.indexing.solr.SolrIndexer; 047import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 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.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.course.CourseContainer; 063import org.ametys.odf.courselist.CourseList; 064import org.ametys.odf.courselist.CourseListContainer; 065import org.ametys.odf.coursepart.CoursePart; 066import org.ametys.odf.program.Program; 067import org.ametys.odf.program.ProgramFactory; 068import org.ametys.odf.program.TraversableProgramPart; 069import org.ametys.plugins.repository.AmetysObject; 070import org.ametys.plugins.repository.AmetysObjectIterable; 071import org.ametys.plugins.repository.AmetysObjectResolver; 072import org.ametys.plugins.repository.AmetysRepositoryException; 073import org.ametys.plugins.repository.ModifiableTraversableAmetysObject; 074import org.ametys.plugins.repository.RepositoryConstants; 075import org.ametys.plugins.repository.TraversableAmetysObject; 076import org.ametys.plugins.repository.UnknownAmetysObjectException; 077import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector; 078import org.ametys.plugins.repository.query.QueryHelper; 079import org.ametys.plugins.repository.query.SortCriteria; 080import org.ametys.plugins.repository.query.expression.AndExpression; 081import org.ametys.plugins.repository.query.expression.Expression; 082import org.ametys.plugins.repository.query.expression.Expression.Operator; 083import org.ametys.plugins.repository.query.expression.StringExpression; 084import org.ametys.runtime.plugin.component.AbstractLogEnabled; 085import org.ametys.runtime.plugin.component.PluginAware; 086 087import com.opensymphony.workflow.WorkflowException; 088 089/** 090 * Component to handle ODF catalogs 091 */ 092public class CatalogsManager extends AbstractLogEnabled implements Serviceable, Component, PluginAware, Contextualizable 093{ 094 /** Avalon Role */ 095 public static final String ROLE = CatalogsManager.class.getName(); 096 097 private AmetysObjectResolver _resolver; 098 099 private CopyCatalogUpdaterExtensionPoint _copyUpdaterEP; 100 101 private ObservationManager _observationManager; 102 103 private CurrentUserProvider _userProvider; 104 105 private ContentWorkflowHelper _contentWorkflowHelper; 106 107 private String _pluginName; 108 109 private ODFHelper _odfHelper; 110 111 private ContentTypeExtensionPoint _cTypeEP; 112 113 private Context _context; 114 115 private SolrIndexer _solrIndexer; 116 117 public void contextualize(Context context) throws ContextException 118 { 119 _context = context; 120 } 121 122 @Override 123 public void service(ServiceManager manager) throws ServiceException 124 { 125 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 126 _copyUpdaterEP = (CopyCatalogUpdaterExtensionPoint) manager.lookup(CopyCatalogUpdaterExtensionPoint.ROLE); 127 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 128 _userProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 129 _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE); 130 _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE); 131 _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 132 _solrIndexer = (SolrIndexer) manager.lookup(SolrIndexer.ROLE); 133 } 134 135 public void setPluginInfo(String pluginName, String featureName, String id) 136 { 137 _pluginName = pluginName; 138 } 139 140 /** 141 * Get the list of catalogs 142 * @return the catalogs 143 */ 144 public List<Catalog> getCatalogs() 145 { 146 List<Catalog> result = new ArrayList<>(); 147 148 TraversableAmetysObject catalogsNode = getCatalogsRootNode(); 149 150 AmetysObjectIterable<Catalog> catalogs = catalogsNode.getChildren(); 151 for (Catalog catalog : catalogs) 152 { 153 result.add(catalog); 154 } 155 156 return result; 157 } 158 159 /** 160 * Get a catalog matching with the given name 161 * @param name The name 162 * @return a catalog, or null if not found 163 */ 164 public Catalog getCatalog(String name) 165 { 166 ModifiableTraversableAmetysObject catalogsNode = getCatalogsRootNode(); 167 168 if (StringUtils.isNotEmpty(name) && catalogsNode.hasChild(name)) 169 { 170 return catalogsNode.getChild(name); 171 } 172 173 // Not found 174 return null; 175 } 176 177 /** 178 * Returns the name of the default catalog 179 * @return the name of the default catalog 180 */ 181 @Callable 182 public String getDefaultCatalogName() 183 { 184 Catalog defaultCatalog = getDefaultCatalog(); 185 return defaultCatalog != null ? defaultCatalog.getName() : null; 186 } 187 188 /** 189 * Returns the default catalog 190 * @return the default catalog or null if no default catalog was defined. 191 */ 192 public Catalog getDefaultCatalog() 193 { 194 List<Catalog> catalogs = getCatalogs(); 195 for (Catalog catalog : catalogs) 196 { 197 if (catalog.isDefault()) 198 { 199 return catalog; 200 } 201 } 202 return null; 203 } 204 205 /** 206 * Get the name of the catalog of a ODF content 207 * @param contentId The id of content 208 * @return The catalog's name 209 */ 210 @Callable 211 public String getContentCatalog(String contentId) 212 { 213 Content content = _resolver.resolveById(contentId); 214 215 if (content instanceof ProgramItem) 216 { 217 return ((ProgramItem) content).getCatalog(); 218 } 219 220 // Get catalog from its parents (unecessary ?) 221 AmetysObject parent = content.getParent(); 222 while (parent != null) 223 { 224 if (parent instanceof ProgramItem) 225 { 226 return ((ProgramItem) parent).getCatalog(); 227 } 228 parent = parent.getParent(); 229 } 230 231 return null; 232 } 233 234 /** 235 * Set the catalog of a content. This will modify recursively the catalog of referenced children 236 * @param catalog The catalog 237 * @param contentId The id of content to edit 238 * @throws WorkflowException if an error occurred 239 */ 240 @Callable 241 public void setContentCatalog(String catalog, String contentId) throws WorkflowException 242 { 243 Content content = _resolver.resolveById(contentId); 244 245 if (content instanceof ProgramItem) 246 { 247 _setCatalog(content, catalog); 248 } 249 else 250 { 251 throw new IllegalArgumentException("You can not edit the catalog of the content " + contentId); 252 } 253 } 254 255 private void _setCatalog (Content content, String catalogName) throws WorkflowException 256 { 257 if (content instanceof ProgramItem) 258 { 259 String oldCatalog = ((ProgramItem) content).getCatalog(); 260 if (!catalogName.equals(oldCatalog)) 261 { 262 ((ProgramItem) content).setCatalog(catalogName); 263 264 if (content instanceof WorkflowAwareContent) 265 { 266 _applyChanges((WorkflowAwareContent) content); 267 } 268 } 269 } 270 else if (content instanceof CoursePart) 271 { 272 String oldCatalog = ((CoursePart) content).getCatalog(); 273 if (!catalogName.equals(oldCatalog)) 274 { 275 ((CoursePart) content).setCatalog(catalogName); 276 277 if (content instanceof WorkflowAwareContent) 278 { 279 _applyChanges((WorkflowAwareContent) content); 280 } 281 } 282 } 283 284 _setCatalogToChildren(content, catalogName); 285 } 286 287 private void _setCatalogToChildren (Content content, String catalogName) throws WorkflowException 288 { 289 if (content instanceof TraversableProgramPart) 290 { 291 String[] children = content.getMetadataHolder().getStringArray(TraversableProgramPart.METADATA_CHILD_PROGRAM_PARTS, new String[0]); 292 for (String id : children) 293 { 294 try 295 { 296 Content child = _resolver.resolveById(id); 297 _setCatalog(child, catalogName); 298 } 299 catch (UnknownAmetysObjectException e) 300 { 301 // Nothing 302 } 303 } 304 } 305 306 if (content instanceof CourseContainer) 307 { 308 for (Course course : ((CourseContainer) content).getCourses()) 309 { 310 _setCatalog(course, catalogName); 311 } 312 } 313 314 if (content instanceof CourseListContainer) 315 { 316 for (CourseList cl : ((CourseListContainer) content).getCourseLists()) 317 { 318 _setCatalog(cl, catalogName); 319 } 320 } 321 322 if (content instanceof Course) 323 { 324 for (CoursePart coursePart : ((Course) content).getCourseParts()) 325 { 326 _setCatalog(coursePart, catalogName); 327 } 328 } 329 } 330 331 private void _applyChanges(WorkflowAwareContent content) throws WorkflowException 332 { 333 ((ModifiableDefaultContent) content).setLastContributor(_userProvider.getUser()); 334 ((ModifiableDefaultContent) content).setLastModified(new Date()); 335 336 // Remove the proposal date. 337 content.setProposalDate(null); 338 339 // Save changes 340 content.saveChanges(); 341 342 // Notify listeners 343 Map<String, Object> eventParams = new HashMap<>(); 344 eventParams.put(ObservationConstants.ARGS_CONTENT, content); 345 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId()); 346 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _userProvider.getUser(), eventParams)); 347 348 _contentWorkflowHelper.doAction(content, 22); 349 } 350 351 /** 352 * Get the root catalogs storage object. 353 * @return the root catalogs node 354 * @throws AmetysRepositoryException if a repository error occurs. 355 */ 356 public ModifiableTraversableAmetysObject getCatalogsRootNode() throws AmetysRepositoryException 357 { 358 String originalWorkspace = null; 359 Request request = ContextHelper.getRequest(_context); 360 try 361 { 362 originalWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request); 363 if (ArchiveConstants.ARCHIVE_WORKSPACE.equals(originalWorkspace)) 364 { 365 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, RepositoryConstants.DEFAULT_WORKSPACE); 366 } 367 368 ModifiableTraversableAmetysObject rootNode = _resolver.resolveByPath("/"); 369 ModifiableTraversableAmetysObject pluginsNode = _getOrCreateNode(rootNode, "ametys:plugins", "ametys:unstructured"); 370 ModifiableTraversableAmetysObject pluginNode = _getOrCreateNode(pluginsNode, _pluginName, "ametys:unstructured"); 371 372 return _getOrCreateNode(pluginNode, "catalogs", "ametys:unstructured"); 373 } 374 catch (AmetysRepositoryException e) 375 { 376 throw new AmetysRepositoryException("Unable to get the ODF catalogs root node", e); 377 } 378 finally 379 { 380 if (ArchiveConstants.ARCHIVE_WORKSPACE.equals(originalWorkspace)) 381 { 382 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, originalWorkspace); 383 } 384 } 385 } 386 387 /** 388 * Create a new catalog 389 * @param name The unique name 390 * @param title The title of catalog 391 * @return the created catalog 392 */ 393 public Catalog createCatalog(String name, String title) 394 { 395 Catalog newCatalog = null; 396 397 ModifiableTraversableAmetysObject catalogsNode = getCatalogsRootNode(); 398 399 newCatalog = catalogsNode.createChild(name, "ametys:catalog"); 400 newCatalog.setTitle(title); 401 402 if (getCatalogs().size() == 1) 403 { 404 newCatalog.setDefault(true); 405 } 406 return newCatalog; 407 } 408 409 /** 410 * Get the programs of a catalog for all languages 411 * @param catalog The code of catalog 412 * @return The programs 413 */ 414 public AmetysObjectIterable<Program> getPrograms (String catalog) 415 { 416 return getPrograms(catalog, null); 417 } 418 419 /** 420 * Get the program's items of a catalog for all languages 421 * @param catalog The code of catalog 422 * @return The {@link ProgramItem} 423 */ 424 public AmetysObjectIterable<ProgramItem> getProgramItems(String catalog) 425 { 426 List<Expression> exprs = new ArrayList<>(); 427 428 exprs.add(_cTypeEP.createHierarchicalCTExpression(ProgramItem.PROGRAM_ITEM_CONTENT_TYPE)); 429 exprs.add(new StringExpression(ProgramItem.METADATA_CATALOG, Operator.EQ, catalog)); 430 431 Expression programItemsExpression = new AndExpression(exprs.toArray(new Expression[exprs.size()])); 432 433 // Add sort criteria to get size 434 SortCriteria sortCriteria = new SortCriteria(); 435 sortCriteria.addCriterion(Content.METADATA_TITLE, true, true); 436 437 String query = ContentQueryHelper.getContentXPathQuery(programItemsExpression, sortCriteria); 438 return _resolver.query(query); 439 } 440 441 /** 442 * Get the programs of a catalog 443 * @param catalog The code of catalog 444 * @param lang The language. Can be null to get programs for all languages 445 * @return The programs 446 */ 447 public AmetysObjectIterable<Program> getPrograms (String catalog, String lang) 448 { 449 List<Expression> exprs = new ArrayList<>(); 450 exprs.add(new ContentTypeExpression(Operator.EQ, ProgramFactory.PROGRAM_CONTENT_TYPE)); 451 exprs.add(new StringExpression(ProgramItem.METADATA_CATALOG, Operator.EQ, catalog)); 452 if (lang != null) 453 { 454 exprs.add(new LanguageExpression(Operator.EQ, lang)); 455 } 456 457 Expression programsExpression = new AndExpression(exprs.toArray(new Expression[exprs.size()])); 458 459 // Add sort criteria to get size 460 SortCriteria sortCriteria = new SortCriteria(); 461 sortCriteria.addCriterion(Content.METADATA_TITLE, true, true); 462 463 String programsQuery = QueryHelper.getXPathQuery(null, ProgramFactory.PROGRAM_NODETYPE, programsExpression, sortCriteria); 464 return _resolver.query(programsQuery); 465 } 466 467 /** 468 * Copy the programs and its hierarchy from a catalog to another. 469 * The referenced courses are NOT copied. 470 * @param catalog The new catalog to populate 471 * @param catalogToCopy The catalog from which we copy the programs. 472 * @throws ProcessingException If an error occurred during copy 473 */ 474 public void copyCatalog(Catalog catalog, Catalog catalogToCopy) throws ProcessingException 475 { 476 String catalogToCopyName = catalogToCopy.getName(); 477 String catalogName = catalog.getName(); 478 String [] handledEventIds = new String[] {ObservationConstants.EVENT_CONTENT_ADDED, ObservationConstants.EVENT_CONTENT_MODIFIED, ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED}; 479 try 480 { 481 Map<String, String> copiedPrograms = new HashMap<>(); 482 Map<String, String> copiedSubPrograms = new HashMap<>(); 483 Map<String, String> copiedContainers = new HashMap<>(); 484 Map<String, String> copiedCourseLists = new HashMap<>(); 485 Map<String, String> copiedCourses = new HashMap<>(); 486 Map<String, String> copiedCourseParts = new HashMap<>(); 487 488 Set<String> copyUpdaters = _copyUpdaterEP.getExtensionsIds(); 489 490 AmetysObjectIterable<Program> programs = getPrograms(catalogToCopyName); 491 492 // Do NOT commit yet to Solr in order to improve perfs 493 _observationManager.addArgumentForEvents(handledEventIds, ObservationConstants.ARGS_CONTENT_COMMIT, false); 494 495 long start = System.currentTimeMillis(); 496 497 getLogger().debug("Begin to iterate over programs for copying them"); 498 499 for (Program program : programs) 500 { 501 if (getLogger().isDebugEnabled()) 502 { 503 getLogger().debug("Start copying program '{}' (name: '{}', title: '{}')...", program.getId(), program.getName(), program.getTitle()); 504 } 505 506 Program newProgram = _odfHelper.copyProgramItem(program, catalogName, true, copiedPrograms, copiedSubPrograms, copiedContainers, copiedCourseLists, copiedCourses, copiedCourseParts); 507 508 for (String updaterId : copyUpdaters) 509 { 510 // Call updaters after copy of program 511 CopyCatalogUpdater updater = _copyUpdaterEP.getExtension(updaterId); 512 updater.updateContent(catalogToCopyName, catalogName, program, newProgram); 513 } 514 } 515 516 for (String updaterId : copyUpdaters) 517 { 518 // Call updaters after full copy of catalog 519 CopyCatalogUpdater updater = _copyUpdaterEP.getExtension(updaterId); 520 updater.updateContents(catalogToCopyName, catalogName, copiedPrograms, copiedSubPrograms, copiedContainers, copiedCourseLists, copiedCourses, copiedCourseParts); 521 } 522 523 // Workflow 524 _addCopyStep(copiedPrograms.values()); 525 _addCopyStep(copiedSubPrograms.values()); 526 _addCopyStep(copiedContainers.values()); 527 _addCopyStep(copiedCourseLists.values()); 528 _addCopyStep(copiedCourses.values()); 529 _addCopyStep(copiedCourseParts.values()); 530 531 if (getLogger().isDebugEnabled()) 532 { 533 getLogger().debug("End of iteration over programs for copying them ({})", Duration.of((System.currentTimeMillis() - start) / 1000, ChronoUnit.SECONDS)); 534 } 535 536 } 537 catch (AmetysRepositoryException | WorkflowException e) 538 { 539 getLogger().error("Copy of items of catalog {} into catalog {} has failed", catalogToCopyName, catalogName); 540 throw new ProcessingException("Failed to copy catalog", e); 541 } 542 finally 543 { 544 _observationManager.removeArgumentForEvents(handledEventIds, ObservationConstants.ARGS_CONTENT_COMMIT); 545 546 // Before trying to commit, be sure all the async observers of the current request are finished 547 for (Future future : _observationManager.getFuturesForRequest()) 548 { 549 try 550 { 551 future.get(); 552 } 553 catch (ExecutionException | InterruptedException e) 554 { 555 getLogger().info("An exception occured when calling #get() on Future result of an observer." , e); 556 } 557 } 558 559 // Commit all uncommited changes 560 try 561 { 562 _solrIndexer.commit(); 563 564 getLogger().debug("Copied contents are now committed into Solr."); 565 } 566 catch (IOException | SolrServerException e) 567 { 568 getLogger().error("Impossible to commit changes", e); 569 } 570 } 571 } 572 573 private void _addCopyStep(Collection<String> contentIds) throws AmetysRepositoryException, WorkflowException 574 { 575 for (String contentId : contentIds) 576 { 577 WorkflowAwareContent content = _resolver.resolveById(contentId); 578 _contentWorkflowHelper.doAction(content, getCopyActionId()); 579 } 580 } 581 582 /** 583 * Get the workflow action id for copy. 584 * @return The workflow action id 585 */ 586 protected int getCopyActionId() 587 { 588 return 210; 589 } 590 591 /** 592 * Delete catalog 593 * @param id the id of catalog 594 */ 595 public void deleteCatalog (String id) 596 { 597 try 598 { 599 Catalog catalog = _resolver.resolveById(id); 600 if (catalog != null) 601 { 602 catalog.remove(); 603 catalog.saveChanges(); 604 } 605 } 606 catch (UnknownAmetysObjectException e) 607 { 608 // Nothing 609 } 610 611 } 612 613 private ModifiableTraversableAmetysObject _getOrCreateNode(ModifiableTraversableAmetysObject parentNode, String nodeName, String nodeType) throws AmetysRepositoryException 614 { 615 ModifiableTraversableAmetysObject definitionsNode; 616 if (parentNode.hasChild(nodeName)) 617 { 618 definitionsNode = parentNode.getChild(nodeName); 619 } 620 else 621 { 622 definitionsNode = parentNode.createChild(nodeName, nodeType); 623 parentNode.saveChanges(); 624 } 625 return definitionsNode; 626 } 627}