001/* 002 * Copyright 2017 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.workflow; 017 018import java.util.ArrayList; 019import java.util.Collection; 020import java.util.Comparator; 021import java.util.HashMap; 022import java.util.HashSet; 023import java.util.List; 024import java.util.Map; 025import java.util.Set; 026import java.util.TreeSet; 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.ArrayUtils; 034import org.apache.commons.lang3.StringUtils; 035 036import org.ametys.cms.CmsConstants; 037import org.ametys.cms.repository.Content; 038import org.ametys.cms.repository.WorkflowAwareContent; 039import org.ametys.cms.workflow.ContentWorkflowHelper; 040import org.ametys.core.ui.Callable; 041import org.ametys.odf.ODFHelper; 042import org.ametys.odf.ProgramItem; 043import org.ametys.odf.course.Course; 044import org.ametys.odf.course.CourseFactory; 045import org.ametys.odf.courselist.CourseListFactory; 046import org.ametys.odf.orgunit.OrgUnit; 047import org.ametys.odf.orgunit.OrgUnitFactory; 048import org.ametys.odf.person.PersonFactory; 049import org.ametys.odf.program.AbstractProgram; 050import org.ametys.odf.program.ContainerFactory; 051import org.ametys.odf.program.ProgramFactory; 052import org.ametys.odf.program.SubProgramFactory; 053import org.ametys.plugins.repository.AmetysObjectResolver; 054import org.ametys.plugins.repository.UnknownAmetysObjectException; 055import org.ametys.plugins.repository.version.VersionAwareAmetysObject; 056import org.ametys.plugins.workflow.support.WorkflowProvider; 057import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow; 058import org.ametys.runtime.plugin.component.AbstractLogEnabled; 059 060import com.opensymphony.workflow.InvalidActionException; 061import com.opensymphony.workflow.WorkflowException; 062import com.opensymphony.workflow.spi.Step; 063 064/** 065 * Helper for ODF contents on their workflow 066 * 067 */ 068public class ODFWorkflowHelper extends AbstractLogEnabled implements Component, Serviceable 069{ 070 /** The component role. */ 071 public static final String ROLE = ODFWorkflowHelper.class.getName(); 072 073 /** The validate step id */ 074 public static final int VALIDATED_STEP_ID = 3; 075 076 /** The action id of global validation */ 077 public static final int VALIDATE_ACTION_ID = 4; 078 079 /** The action id of global unpublishment */ 080 public static final int UNPUBLISH_ACTION_ID = 10; 081 082 /** Constant for storing the result map into the transient variables map. */ 083 protected static final String CONTENTS_IN_ERROR_KEY = "contentsInError"; 084 085 /** Constant for storing the result map into the transient variables map. */ 086 protected static final String VALIDATED_CONTENTS_KEY = "validatedContents"; 087 088 /** Constant for storing the unpublish result map into the transient variables map. */ 089 protected static final String UNPUBLISHED_CONTENTS_KEY = "unpublishedContents"; 090 091 /** The Ametys object resolver */ 092 protected AmetysObjectResolver _resolver; 093 /** The workflow provider */ 094 protected WorkflowProvider _workflowProvider; 095 /** The ODF helper */ 096 protected ODFHelper _odfHelper; 097 /** The workflow helper for contents */ 098 protected ContentWorkflowHelper _contentWorkflowHelper; 099 100 @Override 101 public void service(ServiceManager manager) throws ServiceException 102 { 103 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 104 _workflowProvider = (WorkflowProvider) manager.lookup(WorkflowProvider.ROLE); 105 _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE); 106 _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE); 107 } 108 109 /** 110 * Check if the contents has referenced contents that are not already validated (children excluded) 111 * @param contentIds The id of contents to check 112 * @return A map with success key to true if referenced contents are validated. A map with the invalidated contents otherwise. 113 */ 114 @Callable 115 public Map<String, Object> checkReferences(List<String> contentIds) 116 { 117 Map<String, Object> result = new HashMap<>(); 118 119 List<Map<String, Object>> contentsInError = new ArrayList<>(); 120 121 for (String contentId : contentIds) 122 { 123 Set<Content> invalidatedContents = new TreeSet<>(new ContentTypeComparator()); 124 125 WorkflowAwareContent content = _resolver.resolveById(contentId); 126 _checkValidateStep(content, invalidatedContents, false); 127 128 // Remove initial content from invalidated contents 129 invalidatedContents.remove(content); 130 131 if (!invalidatedContents.isEmpty()) 132 { 133 List<Map<String, Object>> invalidatedContentsAsJson = invalidatedContents.stream() 134 .map(c -> _content2Json(c)) 135 .collect(Collectors.toList()); 136 137 Map<String, Object> contentInError = new HashMap<>(); 138 contentInError.put("id", content.getId()); 139 contentInError.put("code", ((ProgramItem) content).getCode()); 140 contentInError.put("title", content.getTitle()); 141 contentInError.put("invalidatedContents", invalidatedContentsAsJson); 142 contentsInError.add(contentInError); 143 } 144 } 145 146 result.put("contentsInError", contentsInError); 147 result.put("success", contentsInError.isEmpty()); 148 return result; 149 } 150 151 /** 152 * Get the global validation status of a content 153 * @param contentId the id of content 154 * @return the result 155 */ 156 @Callable 157 public Map<String, Object> getGlobalValidationStatus(String contentId) 158 { 159 Map<String, Object> result = new HashMap<>(); 160 161 WorkflowAwareContent waContent = _resolver.resolveById(contentId); 162 163 // Order invalidated contents by types 164 Set<Content> invalidatedContents = getInvalidatedContents(waContent); 165 166 List<Map<String, Object>> invalidatedContentsAsJson = invalidatedContents.stream() 167 .map(c -> _content2Json(c)) 168 .collect(Collectors.toList()); 169 170 result.put("invalidatedContents", invalidatedContentsAsJson); 171 result.put("globalValidated", invalidatedContents.isEmpty()); 172 173 return result; 174 } 175 176 /** 177 * Get the invalidated contents referenced by a ODF content 178 * @param content the initial ODF content 179 * @return the set of referenced invalidated contents 180 */ 181 public Set<Content> getInvalidatedContents(WorkflowAwareContent content) 182 { 183 Set<Content> invalidatedContents = new TreeSet<>(new ContentTypeComparator()); 184 185 _checkValidateStep(content, invalidatedContents, true); 186 187 return invalidatedContents; 188 } 189 190 /** 191 * Determines if a content is already in validated step 192 * @param content The content to test 193 * @return true if the content is already validated 194 */ 195 public boolean isInValidatedStep (WorkflowAwareContent content) 196 { 197 AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(content); 198 long workflowId = content.getWorkflowId(); 199 200 List<Step> steps = workflow.getCurrentSteps(workflowId); 201 for (Step step : steps) 202 { 203 if (step.getStepId() == VALIDATED_STEP_ID) 204 { 205 return true; 206 } 207 } 208 209 return false; 210 } 211 212 private void _checkValidateStep (WorkflowAwareContent content, Set<Content> invalidatedContents, boolean checkChildren) 213 { 214 if (!isInValidatedStep(content)) 215 { 216 invalidatedContents.add(content); 217 } 218 219 if (checkChildren && content instanceof ProgramItem) 220 { 221 // Check the structure recursively 222 List<ProgramItem> children = _odfHelper.getChildProgramItems((ProgramItem) content) 223 .stream() 224 .filter(ProgramItem::isPublishable) 225 .toList(); 226 for (ProgramItem child : children) 227 { 228 WorkflowAwareContent waChild = (WorkflowAwareContent) child; 229 _checkValidateStep(waChild, invalidatedContents, checkChildren); 230 } 231 } 232 233 // Validate others referenced contents 234 if (content instanceof AbstractProgram) 235 { 236 _checkReferencedContents(((AbstractProgram) content).getOrgUnits(), invalidatedContents, checkChildren); 237 _checkReferencedContents(((AbstractProgram) content).getContacts(), invalidatedContents, checkChildren); 238 } 239 else if (content instanceof Course) 240 { 241 _checkReferencedContents(((Course) content).getOrgUnits(), invalidatedContents, checkChildren); 242 _checkReferencedContents(((Course) content).getContacts(), invalidatedContents, checkChildren); 243 } 244 else if (content instanceof OrgUnit) 245 { 246 _checkReferencedContents(((OrgUnit) content).getContacts(), invalidatedContents, checkChildren); 247 } 248 } 249 250 private void _checkReferencedContents(Collection<String> refContentIds, Set<Content> invalidatedContents, boolean recursively) 251 { 252 for (String id : refContentIds) 253 { 254 try 255 { 256 if (StringUtils.isNotEmpty(id)) 257 { 258 WorkflowAwareContent refContent = _resolver.resolveById(id); 259 if (recursively) 260 { 261 _checkValidateStep(refContent, invalidatedContents, recursively); 262 } 263 else if (!isInValidatedStep(refContent)) 264 { 265 invalidatedContents.add(refContent); 266 } 267 } 268 } 269 catch (UnknownAmetysObjectException e) 270 { 271 // Nothing 272 } 273 } 274 } 275 276 /** 277 * Global validation on a contents. 278 * Validate the contents with their whole structure and the others referenced contacts and orgunits. 279 * @param contentIds the id of contents to validation recursively 280 * @return the result for each initial contents 281 */ 282 @Callable 283 public Map<String, Object> globalValidate(List<String> contentIds) 284 { 285 Map<String, Object> result = new HashMap<>(); 286 287 for (String contentId : contentIds) 288 { 289 Map<String, Object> contentResult = new HashMap<>(); 290 291 contentResult.put(CONTENTS_IN_ERROR_KEY, new HashSet<Content>()); 292 contentResult.put(VALIDATED_CONTENTS_KEY, new HashSet<String>()); 293 294 ProgramItem programItem = _resolver.resolveById(contentId); 295 if (programItem.isPublishable()) 296 { 297 _validateRecursively((WorkflowAwareContent) programItem, contentResult); 298 } 299 300 @SuppressWarnings("unchecked") 301 Set<Content> contentsInError = (Set<Content>) contentResult.get(CONTENTS_IN_ERROR_KEY); 302 List<Map<String, Object>> contentsInErrorAsJson = contentsInError.stream() 303 .map(c -> _content2Json(c)) 304 .collect(Collectors.toList()); 305 306 contentResult.put(CONTENTS_IN_ERROR_KEY, contentsInErrorAsJson); 307 308 result.put(contentId, contentResult); 309 } 310 311 return result; 312 } 313 314 /** 315 * Get the JSON representation of the content 316 * @param content the content 317 * @return the content properties 318 */ 319 protected Map<String, Object> _content2Json(Content content) 320 { 321 Map<String, Object> content2json = new HashMap<>(); 322 content2json.put("title", content.getTitle()); 323 content2json.put("id", content.getId()); 324 325 if (content instanceof ProgramItem) 326 { 327 content2json.put("code", ((ProgramItem) content).getCode()); 328 } 329 else if (content instanceof OrgUnit) 330 { 331 content2json.put("code", ((OrgUnit) content).getUAICode()); 332 } 333 334 return content2json; 335 } 336 337 /** 338 * Validate the referenced contents recursively 339 * @param content The validated content 340 * @param result the result object to fill during process 341 */ 342 protected void _validateRecursively (WorkflowAwareContent content, Map<String, Object> result) 343 { 344 @SuppressWarnings("unchecked") 345 Set<String> validatedContentIds = (Set<String>) result.get(VALIDATED_CONTENTS_KEY); 346 @SuppressWarnings("unchecked") 347 Set<Content> contentsInError = (Set<Content>) result.get(CONTENTS_IN_ERROR_KEY); 348 349 if (!isInValidatedStep(content)) 350 { 351 // Validate content itself 352 if (!_doValidateWorkflowAction(content, VALIDATE_ACTION_ID)) 353 { 354 contentsInError.add(content); 355 } 356 else 357 { 358 validatedContentIds.add(content.getId()); 359 } 360 } 361 362 if (content instanceof ProgramItem) 363 { 364 // Validate the structure recursively 365 List<ProgramItem> children = _odfHelper.getChildProgramItems((ProgramItem) content) 366 .stream() 367 .filter(ProgramItem::isPublishable) 368 .toList(); 369 for (ProgramItem child : children) 370 { 371 _validateRecursively((WorkflowAwareContent) child, result); 372 } 373 } 374 375 // Validate others referenced contents 376 if (content instanceof AbstractProgram) 377 { 378 _validateReferencedContents(((AbstractProgram) content).getOrgUnits(), result); 379 _validateReferencedContents(((AbstractProgram) content).getContacts(), result); 380 } 381 else if (content instanceof Course) 382 { 383 _validateReferencedContents(((Course) content).getOrgUnits(), result); 384 _validateReferencedContents(((Course) content).getContacts(), result); 385 } 386 else if (content instanceof OrgUnit) 387 { 388 _validateReferencedContents(((OrgUnit) content).getContacts(), result); 389 } 390 } 391 392 /** 393 * Validate the list of referenced contents 394 * @param refContentIds The id of contents to validate 395 * @param result the result object to fill during process 396 */ 397 protected void _validateReferencedContents (Collection<String> refContentIds, Map<String, Object> result) 398 { 399 @SuppressWarnings("unchecked") 400 Set<String> validatedContentIds = (Set<String>) result.get(VALIDATED_CONTENTS_KEY); 401 @SuppressWarnings("unchecked") 402 Set<Content> contentsInError = (Set<Content>) result.get(CONTENTS_IN_ERROR_KEY); 403 404 for (String id : refContentIds) 405 { 406 try 407 { 408 if (StringUtils.isNotEmpty(id)) 409 { 410 WorkflowAwareContent content = _resolver.resolveById(id); 411 if (!isInValidatedStep(content)) 412 { 413 if (!_doValidateWorkflowAction (content, VALIDATE_ACTION_ID)) 414 { 415 contentsInError.add(content); 416 } 417 else 418 { 419 validatedContentIds.add(content.getId()); 420 } 421 } 422 } 423 } 424 catch (UnknownAmetysObjectException e) 425 { 426 // Nothing 427 } 428 } 429 } 430 431 /** 432 * Validate a content 433 * @param content The content to validate 434 * @param actionId The id of validate action 435 * @return true if the validation success 436 */ 437 protected boolean _doValidateWorkflowAction (WorkflowAwareContent content, int actionId) 438 { 439 try 440 { 441 _contentWorkflowHelper.doAction(content, actionId, new HashMap<>()); 442 return true; 443 } 444 catch (InvalidActionException e) 445 { 446 getLogger().warn("Unable to validate content \"{}\" ({}): mandatory metadata are probably missing or the content is locked", content.getTitle(), content.getId(), e); 447 return false; 448 } 449 catch (WorkflowException e) 450 { 451 getLogger().warn("Failed to validate content \"{}\" ({})", content.getTitle(), content.getId(), e); 452 return false; 453 } 454 } 455 456 /** 457 * Set the publishable state of contents 458 * @param contentIds The id of contents 459 * @param isPublishable <code>true</code> to set content as publishable, <code>false</code> otherwise 460 * @return The result map 461 */ 462 @Callable 463 public Map<String, Object> setPublishableState (List<String> contentIds, boolean isPublishable) 464 { 465 Map<String, Object> result = new HashMap<>(); 466 467 for (String id : contentIds) 468 { 469 Map<String, Object> contentResult = new HashMap<>(); 470 Set<Content> contentsInError = new HashSet<>(); 471 contentResult.put(CONTENTS_IN_ERROR_KEY, contentsInError); 472 contentResult.put(UNPUBLISHED_CONTENTS_KEY, new HashSet<String>()); 473 474 WorkflowAwareContent content = _resolver.resolveById(id); 475 if (content instanceof ProgramItem programItem) 476 { 477 try 478 { 479 programItem.setPublishable(isPublishable); 480 content.saveChanges(); 481 482 if (!isPublishable) 483 { 484 _unpublishRecursively(programItem, contentResult); 485 } 486 } 487 catch (Exception e) 488 { 489 getLogger().error("Unable to set publishable property for content '{}' with id '{}'", content.getTitle(), content.getId(), e); 490 contentsInError.add(content); 491 } 492 493 List<Map<String, Object>> contentsInErrorAsJson = contentsInError.stream() 494 .map(c -> _content2Json(c)) 495 .collect(Collectors.toList()); 496 contentResult.put(CONTENTS_IN_ERROR_KEY, contentsInErrorAsJson); 497 498 result.put(id, contentResult); 499 } 500 } 501 502 return result; 503 } 504 505 /** 506 * Unpublish the referenced contents recursively 507 * @param programItem The content to unpublish 508 * @param result the result object to fill during process 509 */ 510 protected void _unpublishRecursively (ProgramItem programItem, Map<String, Object> result) 511 { 512 @SuppressWarnings("unchecked") 513 Set<String> unpublishedContentIds = (Set<String>) result.get(UNPUBLISHED_CONTENTS_KEY); 514 @SuppressWarnings("unchecked") 515 Set<Content> contentsInError = (Set<Content>) result.get(CONTENTS_IN_ERROR_KEY); 516 517 if (_isPublished(programItem)) 518 { 519 // Unpublish content itself 520 if (!_doUnpublishWorkflowAction((WorkflowAwareContent) programItem, UNPUBLISH_ACTION_ID)) 521 { 522 contentsInError.add((Content) programItem); 523 } 524 else 525 { 526 unpublishedContentIds.add(programItem.getId()); 527 } 528 } 529 530 // Unpublish the structure recursively 531 List<ProgramItem> children = _odfHelper.getChildProgramItems(programItem); 532 for (ProgramItem child : children) 533 { 534 boolean hasOtherPublishedParent = _odfHelper.getParentProgramItems(child) 535 .stream() 536 .filter(p -> !p.getId().equals(programItem.getId())) 537 .filter(this::_isPublished) 538 .findFirst() 539 .isPresent(); 540 541 // Don't unpublish the content if it has an other published parent 542 if (!hasOtherPublishedParent) 543 { 544 _unpublishRecursively(child, result); 545 } 546 } 547 } 548 549 /** 550 * Unpublish a content 551 * @param content The content to unpublish 552 * @param actionId The id of unpublish action 553 * @return true if the unpublish success 554 */ 555 protected boolean _doUnpublishWorkflowAction (WorkflowAwareContent content, int actionId) 556 { 557 try 558 { 559 _contentWorkflowHelper.doAction(content, actionId, new HashMap<>()); 560 return true; 561 } 562 catch (Exception e) 563 { 564 getLogger().warn("Failed to unpublish content \"{}\" ({})", content.getTitle(), content.getId(), e); 565 return false; 566 } 567 } 568 569 /** 570 * <code>true</code> if the parent is publishable 571 * @param content the content 572 * @return <code>true</code> if the parent is publishable 573 */ 574 public boolean isParentPublishable(ProgramItem content) 575 { 576 List<ProgramItem> parents = _odfHelper.getParentProgramItems(content); 577 if (parents.isEmpty()) 578 { 579 return true; 580 } 581 582 return parents.stream() 583 .filter(c -> c.isPublishable() && isParentPublishable(c)) 584 .findAny() 585 .isPresent(); 586 } 587 588 private boolean _isPublished(ProgramItem content) 589 { 590 return content instanceof VersionAwareAmetysObject versionAAO ? ArrayUtils.contains(versionAAO.getAllLabels(), CmsConstants.LIVE_LABEL) : false; 591 } 592 593 class ContentTypeComparator implements Comparator<Content> 594 { 595 String[] _orderedContentTypes = new String[] { 596 ProgramFactory.PROGRAM_CONTENT_TYPE, 597 SubProgramFactory.SUBPROGRAM_CONTENT_TYPE, 598 ContainerFactory.CONTAINER_CONTENT_TYPE, 599 CourseListFactory.COURSE_LIST_CONTENT_TYPE, 600 CourseFactory.COURSE_CONTENT_TYPE, 601 OrgUnitFactory.ORGUNIT_CONTENT_TYPE, 602 PersonFactory.PERSON_CONTENT_TYPE 603 }; 604 605 @Override 606 public int compare(Content c1, Content c2) 607 { 608 if (c1 == c2) 609 { 610 return 0; 611 } 612 613 String cTypeId1 = c1.getTypes()[0]; 614 String cTypeId2 = c2.getTypes()[0]; 615 616 int i1 = ArrayUtils.indexOf(_orderedContentTypes, cTypeId1); 617 int i2 = ArrayUtils.indexOf(_orderedContentTypes, cTypeId2); 618 619 if (i1 == i2) 620 { 621 // order by title for content of same type 622 int compareTo = c1.getTitle().compareTo(c2.getTitle()); 623 if (compareTo == 0) 624 { 625 // for content of same title, order by id to do not return 0 to add it in TreeSet 626 // Indeed, in a TreeSet implementation two elements that are equal by the method compareTo are, from the standpoint of the set, equal 627 return c1.getId().compareTo(c2.getId()); 628 } 629 else 630 { 631 return compareTo; 632 } 633 } 634 635 return i1 != -1 && i1 < i2 ? -1 : 1; 636 } 637 } 638 639}