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