001/* 002 * Copyright 2018 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.helper; 017 018import java.util.ArrayList; 019import java.util.Collection; 020import java.util.Collections; 021import java.util.HashMap; 022import java.util.HashSet; 023import java.util.List; 024import java.util.Map; 025import java.util.Map.Entry; 026import java.util.Set; 027import java.util.stream.Collectors; 028 029import org.apache.avalon.framework.component.Component; 030import org.apache.avalon.framework.service.ServiceException; 031import org.apache.avalon.framework.service.ServiceManager; 032import org.apache.avalon.framework.service.Serviceable; 033import org.apache.commons.lang3.StringUtils; 034import org.apache.commons.lang3.tuple.Pair; 035 036import org.ametys.cms.ObservationConstants; 037import org.ametys.cms.clientsideelement.content.SmartContentClientSideElementHelper; 038import org.ametys.cms.content.ContentHelper; 039import org.ametys.cms.data.ContentDataHelper; 040import org.ametys.cms.indexing.solr.SolrIndexHelper; 041import org.ametys.cms.repository.Content; 042import org.ametys.cms.repository.ModifiableWorkflowAwareContent; 043import org.ametys.cms.repository.WorkflowAwareContent; 044import org.ametys.cms.trash.element.TrashElementDAO; 045import org.ametys.cms.trash.model.TrashElementModel; 046import org.ametys.cms.workflow.ContentWorkflowHelper; 047import org.ametys.cms.workflow.EditContentFunction; 048import org.ametys.core.observation.Event; 049import org.ametys.core.observation.ObservationManager; 050import org.ametys.core.right.RightManager; 051import org.ametys.core.right.RightManager.RightResult; 052import org.ametys.core.user.CurrentUserProvider; 053import org.ametys.odf.ODFHelper; 054import org.ametys.odf.ProgramItem; 055import org.ametys.odf.course.Course; 056import org.ametys.odf.course.CourseFactory; 057import org.ametys.odf.course.ShareableCourseConstants; 058import org.ametys.odf.courselist.CourseList; 059import org.ametys.odf.coursepart.CoursePart; 060import org.ametys.odf.observation.OdfObservationConstants; 061import org.ametys.odf.orgunit.OrgUnit; 062import org.ametys.odf.person.Person; 063import org.ametys.odf.program.Container; 064import org.ametys.odf.program.Program; 065import org.ametys.odf.program.ProgramPart; 066import org.ametys.odf.program.SubProgram; 067import org.ametys.odf.program.TraversableProgramPart; 068import org.ametys.plugins.repository.AmetysObjectIterator; 069import org.ametys.plugins.repository.AmetysObjectResolver; 070import org.ametys.plugins.repository.AmetysRepositoryException; 071import org.ametys.plugins.repository.ModifiableAmetysObject; 072import org.ametys.plugins.repository.RemovableAmetysObject; 073import org.ametys.plugins.repository.lock.LockableAmetysObject; 074import org.ametys.plugins.repository.query.QueryHelper; 075import org.ametys.plugins.repository.query.expression.Expression.Operator; 076import org.ametys.plugins.repository.query.expression.StringExpression; 077import org.ametys.plugins.repository.trash.TrashElement; 078import org.ametys.plugins.repository.trash.TrashableAmetysObject; 079import org.ametys.plugins.workflow.AbstractWorkflowComponent; 080import org.ametys.runtime.plugin.component.AbstractLogEnabled; 081 082import com.opensymphony.workflow.WorkflowException; 083 084/** 085 * Helper to delete an ODF content. 086 */ 087public class DeleteODFContentHelper extends AbstractLogEnabled implements Component, Serviceable 088{ 089 /** Avalon role. */ 090 public static final String ROLE = DeleteODFContentHelper.class.getName(); 091 092 /** Ametys object resolver */ 093 private AmetysObjectResolver _resolver; 094 095 /** The ODF helper */ 096 private ODFHelper _odfHelper; 097 098 /** Observer manager. */ 099 private ObservationManager _observationManager; 100 101 /** The Content workflow helper */ 102 private ContentWorkflowHelper _contentWorkflowHelper; 103 104 /** The current user provider */ 105 private CurrentUserProvider _currentUserProvider; 106 107 /** The rights manager */ 108 private RightManager _rightManager; 109 110 /** The content helper */ 111 private ContentHelper _contentHelper; 112 113 /** Helper for smart content client elements */ 114 private SmartContentClientSideElementHelper _smartHelper; 115 116 private SolrIndexHelper _solrIndexHelper; 117 118 private TrashElementDAO _trashElementDAO; 119 120 public void service(ServiceManager manager) throws ServiceException 121 { 122 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 123 _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE); 124 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 125 _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE); 126 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 127 _rightManager = (RightManager) manager.lookup(RightManager.ROLE); 128 _smartHelper = (SmartContentClientSideElementHelper) manager.lookup(SmartContentClientSideElementHelper.ROLE); 129 _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE); 130 _solrIndexHelper = (SolrIndexHelper) manager.lookup(SolrIndexHelper.ROLE); 131 _trashElementDAO = (TrashElementDAO) manager.lookup(org.ametys.plugins.repository.trash.TrashElementDAO.ROLE); 132 } 133 134 /** 135 * Enumeration for the mode of deletion 136 * 137 */ 138 public enum DeleteMode 139 { 140 /** Delete the content only */ 141 SINGLE, 142 /** Delete the content and its structure */ 143 STRUCTURE_ONLY, 144 /** Delete the content and its structure and its courses */ 145 FULL 146 } 147 148 /** 149 * Trash contents if possible, or delete it. 150 * @param contentsId The ids of contents to delete 151 * @param modeParam The mode of deletion 152 * @return the deleted and undeleted contents 153 */ 154 public Map<String, Object> trashContents(List<String> contentsId, String modeParam) 155 { 156 return trashContents(contentsId, modeParam, false); 157 } 158 159 /** 160 * Trash contents if possible, or delete it. 161 * @param contentsId The ids of contents to delete 162 * @param modeParam The mode of deletion 163 * @param ignoreRights If true, bypass the rights check during the deletion 164 * @return the deleted and undeleted contents 165 */ 166 public Map<String, Object> trashContents(List<String> contentsId, String modeParam, boolean ignoreRights) 167 { 168 return _deleteContents(contentsId, modeParam, ignoreRights, false); 169 } 170 171 /** 172 * Delete ODF contents 173 * @param contentsId The ids of contents to delete 174 * @param modeParam The mode of deletion 175 * @return the deleted and undeleted contents 176 */ 177 public Map<String, Object> deleteContents(List<String> contentsId, String modeParam) 178 { 179 return deleteContents(contentsId, modeParam, false); 180 } 181 182 /** 183 * Delete ODF contents 184 * @param contentsId The ids of contents to delete 185 * @param modeParam The mode of deletion 186 * @param ignoreRights If true, bypass the rights check during the deletion 187 * @return the deleted and undeleted contents 188 */ 189 public Map<String, Object> deleteContents(List<String> contentsId, String modeParam, boolean ignoreRights) 190 { 191 return _deleteContents(contentsId, modeParam, ignoreRights, true); 192 } 193 194 /** 195 * Delete ODF contents 196 * @param contentsId The ids of contents to delete 197 * @param modeParam The mode of deletion 198 * @param ignoreRights If true, bypass the rights check during the deletion 199 * @param onlyDeletion <code>true</code> to really delete the contents, otherwise the contents will be trashed if trashable 200 * @return the deleted and undeleted contents 201 */ 202 private Map<String, Object> _deleteContents(List<String> contentsId, String modeParam, boolean ignoreRights, boolean onlyDeletion) 203 { 204 Map<String, Object> results = new HashMap<>(); 205 206 List<String> alreadyDeletedContentIds = new ArrayList<>(); 207 for (String contentId : contentsId) 208 { 209 Map<String, Object> result = new HashMap<>(); 210 result.put("deleted-contents", new HashSet<>()); 211 result.put("undeleted-contents", new HashSet<>()); 212 result.put("referenced-contents", new HashSet<>()); 213 result.put("unauthorized-contents", new HashSet<>()); 214 result.put("locked-contents", new HashSet<>()); 215 result.put("hierarchy-changed-contents", new HashMap<>()); 216 217 if (!alreadyDeletedContentIds.contains(contentId)) 218 { 219 Content content = _resolver.resolveById(contentId); 220 221 result.put("initial-content", content.getId()); 222 223 DeleteMode deleteMode = StringUtils.isNotBlank(modeParam) ? DeleteMode.valueOf(modeParam.toUpperCase()) : DeleteMode.SINGLE; 224 225 boolean referenced = isContentReferenced(content); 226 if (referenced || !_checkBeforeDeletion(content, deleteMode, ignoreRights, result)) 227 { 228 if (referenced) 229 { 230 // Indicate that the content is referenced. 231 @SuppressWarnings("unchecked") 232 Set<Content> referencedContents = (Set<Content>) result.get("referenced-contents"); 233 referencedContents.add(content); 234 } 235 result.put("check-before-deletion-failed", true); 236 } 237 else 238 { 239 // Process deletion 240 _deleteContent(content, deleteMode, ignoreRights, result, onlyDeletion); 241 242 @SuppressWarnings("unchecked") 243 Set<String> deletedContents = (Set<String>) result.get("deleted-contents"); 244 if (deletedContents != null) 245 { 246 alreadyDeletedContentIds.addAll(deletedContents); 247 } 248 } 249 } 250 else 251 { 252 TrashElement trashElement = _trashElementDAO.find(contentId); 253 if (trashElement != null && trashElement.<Boolean>getValue(TrashElementModel.HIDDEN)) 254 { 255 trashElement.setHidden(false); 256 trashElement.saveChanges(); 257 258 // Notify observers 259 Map<String, Object> eventParams = new HashMap<>(); 260 eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, trashElement.getId()); 261 eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, contentId); 262 _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_UPDATED, _currentUserProvider.getUser(), eventParams)); 263 } 264 } 265 266 results.put(contentId, result); 267 } 268 269 return results; 270 } 271 272 /** 273 * Delete one content 274 * @param content the content to delete 275 * @param deleteMode The deletion mode 276 * @param ignoreRights If true, bypass the rights check during the deletion 277 * @param results the results map 278 */ 279 private void _deleteContent(Content content, DeleteMode deleteMode, boolean ignoreRights, Map<String, Object> results, boolean onlyDeletion) 280 { 281 boolean success = true; 282 283 if (content instanceof OrgUnit) 284 { 285 // 1- First delete relation to parent 286 OrgUnit parentOrgUnit = ((OrgUnit) content).getParentOrgUnit(); 287 if (parentOrgUnit != null) 288 { 289 success = _removeRelation(parentOrgUnit, content, OrgUnit.CHILD_ORGUNITS, 22, results); 290 } 291 292 // 2 - If succeed, process to deletion 293 if (success) 294 { 295 _deleteOrgUnit((OrgUnit) content, ignoreRights, results, onlyDeletion); 296 } 297 } 298 else if (content instanceof ProgramItem) 299 { 300 // 1 - First delete relation to parents 301 if (content instanceof Course) 302 { 303 List<CourseList> courseLists = ((Course) content).getParentCourseLists(); 304 success = _removeRelations(courseLists, content, CourseList.CHILD_COURSES, 22, results); 305 } 306 else if (content instanceof ProgramPart) 307 { 308 List<? extends ModifiableWorkflowAwareContent> parentProgramParts = ((ProgramPart) content).getProgramPartParents() 309 .stream() 310 .filter(ModifiableWorkflowAwareContent.class::isInstance) 311 .map(ModifiableWorkflowAwareContent.class::cast) 312 .collect(Collectors.toList()); 313 314 success = _removeRelations(parentProgramParts, content, TraversableProgramPart.CHILD_PROGRAM_PARTS, 22, results); 315 316 if (success && content instanceof CourseList) 317 { 318 List<Course> parentCourses = ((CourseList) content).getParentCourses(); 319 success = _removeRelations(parentCourses, content, Course.CHILD_COURSE_LISTS, 22, results); 320 } 321 } 322 else 323 { 324 throw new IllegalArgumentException("The content [" + content.getId() + "] is not of the expected type, it can't be deleted."); 325 } 326 327 // 2 - If succeed, process to deletion 328 if (success) 329 { 330 _deleteProgramItem((ProgramItem) content, deleteMode, ignoreRights, results, onlyDeletion); 331 332 // Notify observers for program items that parent relation has been removed 333 @SuppressWarnings("unchecked") 334 Map<String, List<? extends ProgramItem>> hierarchyChangedContents = (Map<String, List<? extends ProgramItem>>) results.get("hierarchy-changed-contents"); 335 for (Entry<String, List< ? extends ProgramItem>> entry : hierarchyChangedContents.entrySet()) 336 { 337 for (ProgramItem programItem : entry.getValue()) 338 { 339 Map<String, Object> eventParams = new HashMap<>(); 340 eventParams.put(OdfObservationConstants.ARGS_PROGRAM_ITEM, programItem); 341 eventParams.put(OdfObservationConstants.ARGS_PROGRAM_ITEM_ID, programItem.getId()); 342 eventParams.put(OdfObservationConstants.ARGS_OLD_PARENT_PROGRAM_ITEM_ID, entry.getKey()); 343 344 _observationManager.notify(new Event(OdfObservationConstants.EVENT_PROGRAM_ITEM_HIERARCHY_CHANGED, _currentUserProvider.getUser(), eventParams)); 345 } 346 } 347 348 } 349 } 350 else if (content instanceof Person) 351 { 352 // 1 - Process to deletion 353 _finalizeDeleteContents(Collections.singleton(new DeletionInfo(content.getId(), List.of(), false)), content.getParent(), results, onlyDeletion); 354 } 355 else 356 { 357 throw new IllegalArgumentException("The content [" + content.getId() + "] is not of the expected type, it can't be deleted."); 358 } 359 360 if (!success) 361 { 362 @SuppressWarnings("unchecked") 363 Set<Content> undeletedContents = (Set<Content>) results.get("undeleted-contents"); 364 undeletedContents.add(content); 365 } 366 } 367 368 /** 369 * Test if content is still referenced before removing it 370 * @param content The content to remove 371 * @return true if content is still referenced 372 */ 373 public boolean isContentReferenced(Content content) 374 { 375 return _isContentReferenced(content, null); 376 } 377 378 /** 379 * Test if content is still referenced before removing it. 380 * @param content The content to remove 381 * @param rootContent the initial content to delete (can be null if checkRoot is false) 382 * @return true if content is still referenced 383 */ 384 private boolean _isContentReferenced(Content content, Content rootContent) 385 { 386 if (content instanceof OrgUnit) 387 { 388 return _isReferencedOrgUnit((OrgUnit) content); 389 } 390 else if (content instanceof ProgramItem) 391 { 392 if (rootContent != null) 393 { 394 return _isReferencedContentCheckingRoot((ProgramItem) content, rootContent); 395 } 396 else 397 { 398 List<ProgramItem> ignoredRefContent = _odfHelper.getChildProgramItems((ProgramItem) content); 399 if (!(content instanceof Program)) 400 { 401 ignoredRefContent.addAll(_odfHelper.getParentProgramItems((ProgramItem) content)); 402 } 403 404 for (Pair<String, Content> refPair : _contentHelper.getReferencingContents(content)) 405 { 406 Content refContent = refPair.getValue(); 407 String path = refPair.getKey(); 408 409 // Ignoring reference from shareable field 410 if (!(refContent instanceof Course && path.equals(ShareableCourseConstants.PROGRAMS_FIELD_ATTRIBUTE_NAME))) 411 { 412 // The ref content is not ignored 413 if (refContent instanceof ProgramItem programItem && !ignoredRefContent.contains(programItem)) 414 { 415 return true; 416 } 417 } 418 } 419 420 return false; 421 } 422 } 423 else if (content instanceof CoursePart) 424 { 425 if (rootContent != null) 426 { 427 // we don't rely on the CoursePart#getCourses method to avoid issue with dirty data 428 String query = QueryHelper.getXPathQuery(null, CourseFactory.COURSE_NODETYPE, new StringExpression(Course.CHILD_COURSE_PARTS, Operator.EQ, content.getId())); 429 AmetysObjectIterator<ProgramItem> iterator = _resolver.<ProgramItem>query(query).iterator(); 430 while (iterator.hasNext()) 431 { 432 if (_isReferencedContentCheckingRoot(iterator.next(), rootContent)) 433 { 434 return true; 435 } 436 437 } 438 439 return false; 440 } 441 // There shouldn't be a case were we try to delete a coursePart without deleting it from a Course. 442 // But in case of we support it 443 else 444 { 445 // Verify if the content has no parent courses 446 if (((CoursePart) content).getCourses().isEmpty()) 447 { 448 // Twice... 449 String query = QueryHelper.getXPathQuery(null, CourseFactory.COURSE_NODETYPE, new StringExpression(Course.CHILD_COURSE_PARTS, Operator.EQ, content.getId())); 450 return _resolver.query(query).iterator().hasNext(); 451 } 452 return true; 453 } 454 } 455 456 return content.hasReferencingContents(); 457 } 458 459 /** 460 * True if the orgUnit is referenced 461 * @param orgUnit the orgUnit 462 * @return true if the orgUnit is referenced 463 */ 464 private boolean _isReferencedOrgUnit(OrgUnit orgUnit) 465 { 466 OrgUnit parentOrgUnit = orgUnit.getParentOrgUnit(); 467 468 List<String> relatedOrgUnit = orgUnit.getSubOrgUnits(); 469 if (parentOrgUnit != null) 470 { 471 relatedOrgUnit.add(parentOrgUnit.getId()); 472 } 473 474 for (Pair<String, Content> refPair : _contentHelper.getReferencingContents(orgUnit)) 475 { 476 Content refContent = refPair.getValue(); 477 String path = refPair.getKey(); 478 479 // Ignoring reference from shareable field 480 if (!(refContent instanceof Course && path.equals(ShareableCourseConstants.ORGUNITS_FIELD_ATTRIBUTE_NAME))) 481 { 482 if (!relatedOrgUnit.contains(refContent.getId())) 483 { 484 return true; 485 } 486 } 487 } 488 489 return false; 490 } 491 492 /** 493 * Check that deletion can be performed without blocking errors 494 * @param content The initial content to delete 495 * @param mode The deletion mode 496 * @param ignoreRights If true, bypass the rights check during the deletion 497 * @param results The results 498 * @return true if the deletion can be performed 499 */ 500 private boolean _checkBeforeDeletion(Content content, DeleteMode mode, boolean ignoreRights, Map<String, Object> results) 501 { 502 // Check right and lock on content it self 503 boolean allRight = _canDeleteContent(content, ignoreRights, results); 504 505 // Check lock on parent contents 506 allRight = _checkParentsBeforeDeletion(content, results) && allRight; 507 508 // Check right and lock on children to be deleted or modified 509 allRight = _checkChildrenBeforeDeletion(content, content, mode, ignoreRights, results) && allRight; 510 511 return allRight; 512 } 513 514 private boolean _checkParentsBeforeDeletion(Content content, Map<String, Object> results) 515 { 516 boolean allRight = true; 517 518 // Check if parents are not locked 519 List< ? extends WorkflowAwareContent> parents = _getParents(content); 520 for (WorkflowAwareContent parent : parents) 521 { 522 if (_smartHelper.isLocked(parent)) 523 { 524 @SuppressWarnings("unchecked") 525 Set<Content> lockedContents = (Set<Content>) results.get("locked-contents"); 526 lockedContents.add(content); 527 528 allRight = false; 529 } 530 } 531 532 return allRight; 533 } 534 535 /** 536 * Browse children to check if deletion could succeed 537 * @param rootContentToDelete The initial content to delete 538 * @param contentToCheck The current content to check 539 * @param mode The deletion mode 540 * @param ignoreRights If true, bypass the rights check during the deletion 541 * @param results The result 542 * @return true if the deletion can be processed 543 */ 544 private boolean _checkChildrenBeforeDeletion(Content rootContentToDelete, Content contentToCheck, DeleteMode mode, boolean ignoreRights, Map<String, Object> results) 545 { 546 boolean allRight = true; 547 548 if (contentToCheck instanceof ProgramItem) 549 { 550 allRight = _checkChildrenBeforeDeletionOfProgramItem(rootContentToDelete, contentToCheck, mode, ignoreRights, results); 551 } 552 else if (contentToCheck instanceof OrgUnit) 553 { 554 allRight = _checkChildrenBeforeDeletionOfOrgUnit(rootContentToDelete, contentToCheck, mode, ignoreRights, results); 555 } 556 557 return allRight; 558 } 559 560 private boolean _checkChildrenBeforeDeletionOfProgramItem(Content rootContentToDelete, Content contentToCheck, DeleteMode mode, boolean ignoreRights, Map<String, Object> results) 561 { 562 boolean allRight = true; 563 564 List<ProgramItem> childProgramItems = _odfHelper.getChildProgramItems((ProgramItem) contentToCheck); 565 for (ProgramItem childProgramItem : childProgramItems) 566 { 567 Content childContent = (Content) childProgramItem; 568 if (_smartHelper.isLocked(childContent)) 569 { 570 // Lock should be checked for all children 571 @SuppressWarnings("unchecked") 572 Set<Content> lockedContents = (Set<Content>) results.get("locked-contents"); 573 lockedContents.add(childContent); 574 575 allRight = false; 576 } 577 578 // If content is not referenced it should be deleted, so check right 579 if ((mode == DeleteMode.FULL 580 || mode == DeleteMode.STRUCTURE_ONLY && !(childProgramItem instanceof Course)) 581 && !_isContentReferenced(childContent, rootContentToDelete) 582 && !ignoreRights 583 && !hasRight(childContent)) 584 { 585 // User has no sufficient right 586 @SuppressWarnings("unchecked") 587 Set<Content> norightContents = (Set<Content>) results.get("unauthorized-contents"); 588 norightContents.add(childContent); 589 590 allRight = false; 591 } 592 593 if (mode != DeleteMode.SINGLE && !(mode == DeleteMode.STRUCTURE_ONLY && childProgramItem instanceof Course)) 594 { 595 // Browse children recursively 596 allRight = _checkChildrenBeforeDeletion(rootContentToDelete, childContent, mode, ignoreRights, results) && allRight; 597 } 598 } 599 600 return allRight; 601 } 602 603 private boolean _checkChildrenBeforeDeletionOfOrgUnit(Content rootContentToDelete, Content contentToCheck, DeleteMode mode, boolean ignoreRights, Map<String, Object> results) 604 { 605 boolean allRight = true; 606 607 List<String> childOrgUnits = ((OrgUnit) contentToCheck).getSubOrgUnits(); 608 for (String childOrgUnitId : childOrgUnits) 609 { 610 OrgUnit childOrgUnit = _resolver.resolveById(childOrgUnitId); 611 if (!_canDeleteContent(childOrgUnit, ignoreRights, results)) 612 { 613 allRight = false; 614 } 615 else if (_isReferencedOrgUnit(childOrgUnit)) 616 { 617 @SuppressWarnings("unchecked") 618 Set<Content> referencedContents = (Set<Content>) results.get("referenced-contents"); 619 referencedContents.add(childOrgUnit); 620 621 allRight = false; 622 } 623 else 624 { 625 // Browse children recursively 626 allRight = _checkChildrenBeforeDeletion(rootContentToDelete, childOrgUnit, mode, ignoreRights, results) && allRight; 627 } 628 } 629 630 return allRight; 631 } 632 633 /** 634 * Remove the relations between the content and its contents list 635 * @param contentsToEdit the contents to edit 636 * @param refContentToRemove The referenced content to be removed from contents 637 * @param attributeName The name of attribute holding the relationship 638 * @param actionId The id of workflow action to edit the relation 639 * @param results the results map 640 * @return true if remove relation successfully 641 */ 642 private boolean _removeRelations(List<? extends ModifiableWorkflowAwareContent> contentsToEdit, Content refContentToRemove, String attributeName, int actionId, Map<String, Object> results) 643 { 644 boolean success = true; 645 646 for (ModifiableWorkflowAwareContent contentToEdit : contentsToEdit) 647 { 648 success = _removeRelation(contentToEdit, refContentToRemove, attributeName, actionId, results) && success; 649 } 650 651 return success; 652 } 653 654 /** 655 * Remove the relation parent-child relation on content. 656 * @param contentToEdit The content to modified 657 * @param refContentToRemove The referenced content to be removed from content 658 * @param attributeName The name of attribute holding the child or parent relationship 659 * @param actionId The id of workflow action to edit the relation 660 * @param results the results map 661 * @return boolean true if remove relation successfully 662 */ 663 private boolean _removeRelation(ModifiableWorkflowAwareContent contentToEdit, Content refContentToRemove, String attributeName, int actionId, Map<String, Object> results) 664 { 665 try 666 { 667 List<String> values = ContentDataHelper.getContentIdsListFromMultipleContentData(contentToEdit, attributeName); 668 669 if (values.contains(refContentToRemove.getId())) 670 { 671 values.remove(refContentToRemove.getId()); 672 673 Map<String, Object> inputs = new HashMap<>(); 674 Map<String, Object> parameters = new HashMap<>(); 675 676 parameters.put(EditContentFunction.VALUES_KEY, Map.of(attributeName, values)); 677 // Unlock the content 678 parameters.put(EditContentFunction.QUIT, true); 679 inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, parameters); 680 // Do not edit the invert relation, we want to keep the relation on the content we are deleting 681 inputs.put(EditContentFunction.INVERT_RELATION_ENABLED, false); 682 683 _contentWorkflowHelper.doAction(contentToEdit, actionId, inputs); 684 } 685 686 return true; 687 } 688 catch (WorkflowException | AmetysRepositoryException e) 689 { 690 getLogger().error("Unable to remove relationship to content {} ({}) on content {} ({}) for metadata {}", refContentToRemove.getTitle(), refContentToRemove.getId(), contentToEdit.getTitle(), contentToEdit.getId(), attributeName, e); 691 return false; 692 } 693 } 694 695 /** 696 * Delete a program item 697 * @param item The program item to delete 698 * @param mode The deletion mode 699 * @param ignoreRights If true, bypass the rights check during the deletion 700 * @param results The results 701 */ 702 private void _deleteProgramItem(ProgramItem item, DeleteMode mode, boolean ignoreRights, Map<String, Object> results, boolean onlyDeletion) 703 { 704 if (mode == DeleteMode.SINGLE) 705 { 706 if (_canDeleteContent((Content) item, ignoreRights, results)) 707 { 708 Set<DeletionInfo> toDelete = new HashSet<>(); 709 String idToDelete = item.getId(); 710 List<String> linkedContents = new ArrayList<>(); 711 712 toDelete.add(new DeletionInfo(idToDelete, linkedContents, false)); 713 // 1 - First remove relations with children 714 String parentMetadataName; 715 if (item instanceof Course course) 716 { 717 parentMetadataName = CourseList.PARENT_COURSES; 718 linkedContents.addAll(course.getCourseParts().stream().map(Content::getId).toList()); 719 toDelete.addAll(_getCoursePartsToDelete(course, item, results)); 720 } 721 else if (item instanceof CourseList) 722 { 723 parentMetadataName = Course.PARENT_COURSE_LISTS; 724 } 725 else 726 { 727 parentMetadataName = ProgramPart.PARENT_PROGRAM_PARTS; 728 } 729 730 List<ProgramItem> childProgramItems = _odfHelper.getChildProgramItems(item); 731 List<ModifiableWorkflowAwareContent> childContents = childProgramItems.stream() 732 .filter(ModifiableWorkflowAwareContent.class::isInstance) 733 .map(ModifiableWorkflowAwareContent.class::cast) 734 .collect(Collectors.toList()); 735 736 linkedContents.addAll(childContents.stream().map(Content::getId).toList()); 737 738 boolean success = _removeRelations(childContents, (Content) item, parentMetadataName, 22, results); 739 if (success) 740 { 741 // If success delete course 742 _finalizeDeleteContents(toDelete, item.getParent(), results, onlyDeletion); 743 744 @SuppressWarnings("unchecked") 745 Set<String> deletedContents = (Set<String>) results.get("deleted-contents"); 746 if (deletedContents.contains(idToDelete)) 747 { 748 @SuppressWarnings("unchecked") 749 Map<String, List<? extends ProgramItem>> hierarchyChangedContents = (Map<String, List<? extends ProgramItem>>) results.get("hierarchy-changed-contents"); 750 hierarchyChangedContents.put(idToDelete, childProgramItems); 751 } 752 } 753 } 754 } 755 else 756 { 757 Set<DeletionInfo> toDelete = _getChildrenIdToDelete(item, item, results, mode, ignoreRights); 758 _finalizeDeleteContents(toDelete, item.getParent(), results, onlyDeletion); 759 } 760 } 761 762 private Set<DeletionInfo> _getCoursePartsToDelete(Course course, ProgramItem initialContentToDelete, Map<String, Object> results) 763 { 764 Set<DeletionInfo> toDelete = new HashSet<>(); 765 for (CoursePart childCoursePart : course.getCourseParts()) 766 { 767 // check if the coursePart is referenced 768 if (!_isContentReferenced(childCoursePart, (Content) initialContentToDelete)) 769 { 770 // we don't check if we can delete the coursePart as we have already check it's course 771 // we can add it to the list of content that will be deleted 772 toDelete.add(new DeletionInfo(childCoursePart.getId(), List.of(), true)); 773 } 774 // the content is still referenced, so we remove the relation from the course part 775 else 776 { 777 _removeRelation(childCoursePart, course, CoursePart.PARENT_COURSES, 22, results); 778 } 779 } 780 return toDelete; 781 } 782 783 /** 784 * Delete one orgUnit 785 * @param orgUnit the orgUnit to delete 786 * @param ignoreRights If true, bypass the rights check during the deletion 787 * @param results the results map 788 */ 789 @SuppressWarnings("unchecked") 790 private void _deleteOrgUnit(OrgUnit orgUnit, boolean ignoreRights, Map<String, Object> results, boolean onlyDeletion) 791 { 792 Collection<DeletionInfo> deletionInfos = _getOrgUnitDeletionInfos(orgUnit, ignoreRights, true, results); 793 794 Set<Content> referencedContents = (Set<Content>) results.get("referenced-contents"); 795 Set<Content> lockedContents = (Set<Content>) results.get("locked-contents"); 796 Set<Content> unauthorizedContents = (Set<Content>) results.get("unauthorized-contents"); 797 798 if (referencedContents.size() == 0 && lockedContents.size() == 0 && unauthorizedContents.size() == 0) 799 { 800 _finalizeDeleteContents(deletionInfos, orgUnit.getParent(), results, onlyDeletion); 801 } 802 } 803 804 /** 805 * Finalize the deletion of contents. Call observers and remove contents 806 * @param toDelete the list of info for each deletion operation to perform 807 * @param parent the jcr parent for saving changes 808 * @param results the results map 809 */ 810 private void _finalizeDeleteContents(Collection<DeletionInfo> toDelete, ModifiableAmetysObject parent, Map<String, Object> results, boolean onlyDeletion) 811 { 812 @SuppressWarnings("unchecked") 813 Set<Content> unauthorizedContents = (Set<Content>) results.get("unauthorized-contents"); 814 @SuppressWarnings("unchecked") 815 Set<Content> lockedContents = (Set<Content>) results.get("locked-contents"); 816 817 if (!unauthorizedContents.isEmpty() || !lockedContents.isEmpty()) 818 { 819 //Do Nothing 820 return; 821 } 822 823 try 824 { 825 _solrIndexHelper.pauseSolrCommitForEvents(new String[] {ObservationConstants.EVENT_CONTENT_DELETED}); 826 827 Map<String, Map<String, Object>> eventParams = new HashMap<>(); 828 for (DeletionInfo info : toDelete) 829 { 830 String id = info.contentId(); 831 Content content = _resolver.resolveById(id); 832 Map<String, Object> eventParam = _getEventParametersForDeletion(content); 833 834 eventParams.put(id, eventParam); 835 836 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParam)); 837 838 // Remove the content. 839 LockableAmetysObject lockedContent = (LockableAmetysObject) content; 840 if (lockedContent.isLocked()) 841 { 842 lockedContent.unlock(); 843 } 844 845 if (!onlyDeletion && content instanceof TrashableAmetysObject trashableAO) 846 { 847 _trashElementDAO.trash(trashableAO, info.hidden(), info.linkedContents().toArray(String[]::new)); 848 } 849 else 850 { 851 ((RemovableAmetysObject) content).remove(); 852 } 853 } 854 855 parent.saveChanges(); 856 857 for (DeletionInfo info : toDelete) 858 { 859 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams.get(info.contentId()))); 860 861 @SuppressWarnings("unchecked") 862 Set<String> deletedContents = (Set<String>) results.get("deleted-contents"); 863 deletedContents.add(info.contentId()); 864 } 865 } 866 finally 867 { 868 _solrIndexHelper.restartSolrCommitForEvents(new String[] {ObservationConstants.EVENT_CONTENT_DELETED}); 869 } 870 } 871 872 /** 873 * True if we can delete the content (check if removable, rights and if locked) 874 * @param content the content 875 * @param ignoreRights If true, bypass the rights check during the deletion 876 * @param results the results map 877 * @return true if we can delete the content 878 */ 879 private boolean _canDeleteContent(Content content, boolean ignoreRights, Map<String, Object> results) 880 { 881 if (!(content instanceof RemovableAmetysObject)) 882 { 883 throw new IllegalArgumentException("The content [" + content.getId() + "] is not a RemovableAmetysObject, it can't be deleted."); 884 } 885 886 if (!ignoreRights && !hasRight(content)) 887 { 888 // User has no sufficient right 889 @SuppressWarnings("unchecked") 890 Set<Content> norightContents = (Set<Content>) results.get("unauthorized-contents"); 891 norightContents.add(content); 892 893 return false; 894 } 895 else if (_smartHelper.isLocked(content)) 896 { 897 @SuppressWarnings("unchecked") 898 Set<Content> lockedContents = (Set<Content>) results.get("locked-contents"); 899 lockedContents.add(content); 900 901 return false; 902 } 903 904 return true; 905 } 906 907 /** 908 * Get parameters for content deleted {@link Event} 909 * @param content the removed content 910 * @return the event's parameters 911 */ 912 private Map<String, Object> _getEventParametersForDeletion (Content content) 913 { 914 Map<String, Object> eventParams = new HashMap<>(); 915 eventParams.put(ObservationConstants.ARGS_CONTENT, content); 916 eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName()); 917 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId()); 918 return eventParams; 919 } 920 921 private List<? extends WorkflowAwareContent> _getParents(Content content) 922 { 923 if (content instanceof OrgUnit) 924 { 925 return Collections.singletonList(((OrgUnit) content).getParentOrgUnit()); 926 } 927 else if (content instanceof Course) 928 { 929 return ((Course) content).getParentCourseLists(); 930 } 931 else if (content instanceof CourseList) 932 { 933 List<ProgramPart> parentProgramItems = ((CourseList) content).getProgramPartParents(); 934 List<WorkflowAwareContent> parents = parentProgramItems.stream().map(p -> (WorkflowAwareContent) p).collect(Collectors.toList()); 935 936 List<Course> parentCourses = ((CourseList) content).getParentCourses(); 937 parents.addAll(parentCourses.stream().map(p -> (WorkflowAwareContent) p).collect(Collectors.toList())); 938 939 return parents; 940 } 941 else if (content instanceof ProgramPart) 942 { 943 List<ProgramItem> parentProgramItems = _odfHelper.getParentProgramItems((ProgramPart) content); 944 return parentProgramItems.stream().map(p -> (WorkflowAwareContent) p).collect(Collectors.toList()); 945 } 946 947 return Collections.EMPTY_LIST; 948 } 949 950 /** 951 * Get the id of children to be deleted. 952 * All children shared with other contents which are not part of deletion, will be not deleted. 953 * @param orgUnit The orgunit to delete 954 * @param ignoreRights If true, bypass the rights check during the deletion 955 * @param results The results 956 * @return The id of contents to be deleted 957 */ 958 private Collection<DeletionInfo> _getOrgUnitDeletionInfos (OrgUnit orgUnit, boolean ignoreRights, boolean userTarget, Map<String, Object> results) 959 { 960 List<DeletionInfo> toDelete = new ArrayList<>(); 961 962 if (_canDeleteContent(orgUnit, ignoreRights, results)) 963 { 964 List<String> subOrgUnits = orgUnit.getSubOrgUnits(); 965 toDelete.add(new DeletionInfo(orgUnit.getId(), subOrgUnits, !userTarget)); 966 967 for (String childId : subOrgUnits) 968 { 969 OrgUnit childOrgUnit = _resolver.resolveById(childId); 970 971 if (!_isReferencedOrgUnit(orgUnit)) 972 { 973 toDelete.addAll(_getOrgUnitDeletionInfos(childOrgUnit, ignoreRights, false, results)); 974 } 975 else 976 { 977 // The child program item can not be deleted, list the relation to the parent and stop iteration 978 @SuppressWarnings("unchecked") 979 Set<Content> referencedContents = (Set<Content>) results.get("referenced-contents"); 980 referencedContents.add(childOrgUnit); 981 } 982 } 983 } 984 985 return toDelete; 986 } 987 988 /** 989 * Get the id of children to be deleted. 990 * All children shared with other contents which are not part of deletion, will be not deleted. 991 * @param contentToDelete The content to delete in the tree of initial content to delete 992 * @param initialContentToDelete The initial content to delete 993 * @param results The results 994 * @param mode The deletion mode 995 * @param ignoreRights If true, bypass the rights check during the deletion 996 * @return The id of contents to be deleted 997 */ 998 private Set<DeletionInfo> _getChildrenIdToDelete (ProgramItem contentToDelete, ProgramItem initialContentToDelete, Map<String, Object> results, DeleteMode mode, boolean ignoreRights) 999 { 1000 Set<DeletionInfo> toDelete = new HashSet<>(); 1001 1002 if (_canDeleteContent((Content) contentToDelete, ignoreRights, results)) 1003 { 1004 List<String> linkedContents = new ArrayList<>(); 1005 toDelete.add(new DeletionInfo(contentToDelete.getId(), linkedContents, !contentToDelete.equals(initialContentToDelete))); 1006 1007 // First we start by adding the coursePart if it's a Course 1008 if (contentToDelete instanceof Course course) 1009 { 1010 linkedContents.addAll(course.getCourseParts().stream().map(Content::getId).toList()); 1011 toDelete.addAll(_getCoursePartsToDelete(course, initialContentToDelete, results)); 1012 } 1013 1014 List<ProgramItem> childProgramItems; 1015 if (mode == DeleteMode.STRUCTURE_ONLY && contentToDelete instanceof TraversableProgramPart) 1016 { 1017 // Get subprogram, container and course list children only 1018 childProgramItems = ((TraversableProgramPart) contentToDelete).getProgramPartChildren().stream().map(ProgramItem.class::cast).collect(Collectors.toList()); 1019 } 1020 else 1021 { 1022 childProgramItems = _odfHelper.getChildProgramItems(contentToDelete); 1023 } 1024 1025 for (ProgramItem childProgramItem : childProgramItems) 1026 { 1027 linkedContents.add(childProgramItem.getId()); 1028 if (!_isContentReferenced((Content) childProgramItem, (Content) initialContentToDelete)) 1029 { 1030 // If all references of this program item is part of the initial content to delete, it can be deleted 1031 if (mode == DeleteMode.STRUCTURE_ONLY && childProgramItem instanceof CourseList courseList) 1032 { 1033 // Remove the relations to the course list to be deleted on all child courses 1034 List<Course> courses = courseList.getCourses(); 1035 toDelete.add(new DeletionInfo(childProgramItem.getId(), courses.stream().map(Content::getId).toList(), true)); 1036 _removeRelations(courses, (Content) childProgramItem, Course.PARENT_COURSE_LISTS, 22, results); 1037 1038 @SuppressWarnings("unchecked") 1039 Map<String, List<? extends ProgramItem>> hierarchyChangedContents = (Map<String, List<? extends ProgramItem>>) results.get("hierarchy-changed-contents"); 1040 hierarchyChangedContents.put(childProgramItem.getId(), ((CourseList) childProgramItem).getCourses()); 1041 } 1042 else 1043 { 1044 // Browse children recursively 1045 toDelete.addAll(_getChildrenIdToDelete(childProgramItem, initialContentToDelete, results, mode, ignoreRights)); 1046 } 1047 } 1048 else 1049 { 1050 // The child program item can not be deleted, remove the relation to the parent and stop iteration 1051 String parentMetadataName; 1052 if (childProgramItem instanceof CourseList) 1053 { 1054 parentMetadataName = contentToDelete instanceof Course ? CourseList.PARENT_COURSES : ProgramPart.PARENT_PROGRAM_PARTS; 1055 } 1056 else if (childProgramItem instanceof Course) 1057 { 1058 parentMetadataName = Course.PARENT_COURSE_LISTS; 1059 } 1060 else 1061 { 1062 parentMetadataName = ProgramPart.PARENT_PROGRAM_PARTS; 1063 } 1064 1065 _removeRelation((ModifiableWorkflowAwareContent) childProgramItem, (Content) contentToDelete, parentMetadataName, 22, results); 1066 1067 @SuppressWarnings("unchecked") 1068 Set<Content> referencedContents = (Set<Content>) results.get("referenced-contents"); 1069 referencedContents.add((Content) childProgramItem); 1070 1071 @SuppressWarnings("unchecked") 1072 Map<String, List<? extends ProgramItem>> hierarchyChangedContents = (Map<String, List<? extends ProgramItem>>) results.get("hierarchy-changed-contents"); 1073 hierarchyChangedContents.put(contentToDelete.getId(), List.of(childProgramItem)); 1074 } 1075 } 1076 } 1077 1078 return toDelete; 1079 } 1080 1081 /** 1082 * Determines if the user has sufficient right for the given content 1083 * @param content the content 1084 * @return true if user has sufficient right 1085 */ 1086 public boolean hasRight(Content content) 1087 { 1088 String rightId = _getRightId(content); 1089 return _rightManager.hasRight(_currentUserProvider.getUser(), rightId, content) == RightResult.RIGHT_ALLOW; 1090 } 1091 1092 private String _getRightId (Content content) 1093 { 1094 if (content instanceof Course) 1095 { 1096 return "ODF_Rights_Course_Delete"; 1097 } 1098 else if (content instanceof SubProgram) 1099 { 1100 return "ODF_Rights_SubProgram_Delete"; 1101 } 1102 else if (content instanceof Container) 1103 { 1104 return "ODF_Rights_Container_Delete"; 1105 } 1106 else if (content instanceof Program) 1107 { 1108 return "ODF_Rights_Program_Delete"; 1109 } 1110 else if (content instanceof Person) 1111 { 1112 return "ODF_Rights_Person_Delete"; 1113 } 1114 else if (content instanceof OrgUnit) 1115 { 1116 return "ODF_Rights_OrgUnit_Delete"; 1117 } 1118 else if (content instanceof CourseList) 1119 { 1120 return "ODF_Rights_CourseList_Delete"; 1121 } 1122 return "CMS_Rights_DeleteContent"; 1123 } 1124 1125 /** 1126 * True if the content is referenced (we are ignoring parent references if they have same root) 1127 * @param programItem the program item 1128 * @param initialContentToDelete the initial content to delete 1129 * @return true if the content is referenced 1130 */ 1131 private boolean _isReferencedContentCheckingRoot(ProgramItem programItem, Content initialContentToDelete) 1132 { 1133 if (programItem.getId().equals(initialContentToDelete.getId())) 1134 { 1135 return false; 1136 } 1137 1138 List<ProgramItem> parentProgramItems = _odfHelper.getParentProgramItems(programItem); 1139 if (parentProgramItems.isEmpty()) 1140 { 1141 // We have found the root parent of our item. but it's not the initial content to delete 1142 return true; 1143 } 1144 1145 for (ProgramItem parentProgramItem : parentProgramItems) 1146 { 1147 if (_isReferencedContentCheckingRoot(parentProgramItem, initialContentToDelete)) 1148 { 1149 return true; 1150 } 1151 } 1152 1153 return false; 1154 } 1155 1156 private record DeletionInfo(String contentId, Collection<String> linkedContents, boolean hidden) { } 1157}