001/* 002 * Copyright 2020 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 */ 016 017package org.ametys.plugins.odfpilotage.helper; 018 019import java.util.ArrayList; 020import java.util.Collections; 021import java.util.HashMap; 022import java.util.List; 023import java.util.Map; 024import java.util.Map.Entry; 025import java.util.Objects; 026import java.util.Optional; 027import java.util.TreeMap; 028import java.util.stream.Collectors; 029import java.util.stream.Stream; 030 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.cocoon.components.ContextHelper; 037import org.apache.cocoon.environment.Request; 038import org.apache.cocoon.environment.Session; 039import org.apache.commons.lang3.StringUtils; 040 041import org.ametys.cms.repository.Content; 042import org.ametys.cms.repository.ModifiableDefaultContent; 043import org.ametys.cms.workflow.ContentWorkflowHelper; 044import org.ametys.cms.workflow.EditContentAccessDeniedException; 045import org.ametys.core.ui.Callable; 046import org.ametys.core.util.I18nUtils; 047import org.ametys.odf.ProgramItem; 048import org.ametys.odf.course.Course; 049import org.ametys.odf.courselist.CourseList; 050import org.ametys.odf.courselist.CourseListContainer; 051import org.ametys.odf.coursepart.CoursePart; 052import org.ametys.odf.orgunit.OrgUnit; 053import org.ametys.odf.orgunit.OrgUnitFactory; 054import org.ametys.odf.program.Program; 055import org.ametys.plugins.contentstree.TreeConfiguration; 056import org.ametys.plugins.odfpilotage.cost.CostComputationComponent; 057import org.ametys.plugins.odfpilotage.cost.entity.CostComputationData; 058import org.ametys.plugins.odfpilotage.cost.entity.Effectives; 059import org.ametys.plugins.odfpilotage.cost.entity.EqTD; 060import org.ametys.plugins.odfpilotage.cost.entity.Groups; 061import org.ametys.plugins.odfpilotage.cost.entity.NormDetails; 062import org.ametys.plugins.odfpilotage.cost.entity.OverriddenData; 063import org.ametys.plugins.odfpilotage.cost.entity.ProgramItemData; 064import org.ametys.plugins.odfpilotage.cost.entity.VolumesOfHours; 065import org.ametys.plugins.repository.AmetysObjectIterable; 066import org.ametys.plugins.repository.UnknownAmetysObjectException; 067import org.ametys.plugins.repository.query.QueryHelper; 068import org.ametys.runtime.model.ModelItem; 069import org.ametys.runtime.model.View; 070 071import com.opensymphony.workflow.InvalidActionException; 072import com.opensymphony.workflow.WorkflowException; 073 074/** 075 * This component handle the content of the cost modeling tool 076 */ 077public class CostComputationTreeHelper extends ODFContentsTreeHelper implements Contextualizable 078{ 079 private static int _ACTION = 2; 080 081 private static final String __COST_DATA_KEY = "cost-data-key"; 082 083 /** The cost computation component */ 084 protected CostComputationComponent _costComputationComponent; 085 086 /** Workflow helper component */ 087 protected ContentWorkflowHelper _contentWorkflowHelper; 088 089 /** The I18N utils */ 090 protected I18nUtils _i18nUtils; 091 092 /** The context */ 093 protected Context _context; 094 095 @Override 096 public void contextualize(Context context) throws ContextException 097 { 098 _context = context; 099 } 100 101 @Override 102 public void service(ServiceManager smanager) throws ServiceException 103 { 104 super.service(smanager); 105 _costComputationComponent = (CostComputationComponent) smanager.lookup(CostComputationComponent.ROLE); 106 _contentWorkflowHelper = (ContentWorkflowHelper) smanager.lookup(ContentWorkflowHelper.ROLE); 107 _i18nUtils = (I18nUtils) smanager.lookup(I18nUtils.ROLE); 108 } 109 110 /** 111 * Get the cost data from user session 112 * @return the cost data 113 */ 114 protected CostComputationData _getCostData() 115 { 116 Request request = ContextHelper.getRequest(_context); 117 Session session = request.getSession(); 118 119 CostComputationData costData = (CostComputationData) session.getAttribute(__COST_DATA_KEY); 120 if (costData == null) 121 { 122 throw new UnsupportedOperationException("Cost data is not initalized in session attribute '" + __COST_DATA_KEY + "'"); 123 } 124 125 return costData; 126 } 127 128 /** 129 * Set the cost data to user session 130 * @param costData the cost data 131 */ 132 protected void _setCostData(CostComputationData costData) 133 { 134 Request request = ContextHelper.getRequest(_context); 135 Session session = request.getSession(); 136 137 session.setAttribute(__COST_DATA_KEY, costData); 138 } 139 140 /** 141 * Launch the cost computation component algorithm 142 * @param content the content to compute 143 * @param catalog the catalog 144 * @param lang the lang 145 * @param overriddenData overridden data by the user 146 */ 147 protected void _launchCostComputation(Content content, String catalog, String lang, OverriddenData overriddenData) 148 { 149 CostComputationData costData = content instanceof OrgUnit 150 ? _costComputationComponent.computeCostsOnOrgUnit((OrgUnit) content, catalog, lang, overriddenData) 151 : (content instanceof Program 152 ? _costComputationComponent.computeCostsOnProgram((Program) content, overriddenData) 153 : null 154 ); 155 156 if (costData != null) 157 { 158 _setCostData(costData); 159 } 160 } 161 162 /** 163 * Get the children contents according the tree configuration 164 * @param parentContent the root content 165 * @param treeConfiguration the tree configuration 166 * @param catalog the catalog 167 * @param lang the lang 168 * @return the children content for each child attributes 169 */ 170 protected Map<String, List<Content>> _getChildrenContent(Content parentContent, TreeConfiguration treeConfiguration, String catalog, String lang) 171 { 172 Map<String, List<Content>> children = new HashMap<>(); 173 if (parentContent instanceof OrgUnit) 174 { 175 OrgUnit orgUnit = (OrgUnit) parentContent; 176 List<Content> contents = orgUnit.getSubOrgUnits() 177 .stream() 178 .map(this::_resolveSilently) 179 .filter(Objects::nonNull) 180 .collect(Collectors.toList()); 181 if (orgUnit.getParentOrgUnit() == null && !contents.isEmpty()) 182 { 183 children.put("childOrgUnits", contents); 184 } 185 else 186 { 187 List<Program> programs = _odfHelper.getProgramsFromOrgUnit((OrgUnit) parentContent, catalog, lang); 188 contents = programs.stream() 189 .filter(Content.class::isInstance) 190 .map(Content.class::cast) 191 .collect(Collectors.toList()); 192 193 // _programsLink n'est pas un vrai attribut, cet identifiant n'est jamais utilisé 194 children.put("_programsLink", contents); 195 } 196 } 197 else 198 { 199 children = super.getChildrenContent(parentContent, treeConfiguration); 200 } 201 return children; 202 } 203 204 /** 205 * Get the children contents according the tree configuration 206 * @param contentId the parent content 207 * @param treeId the tree configuration 208 * @param contentPath the content path 209 * @param catalog the catalog 210 * @param lang the lang 211 * @return the children content 212 */ 213 @Callable 214 public Map<String, Object> getChildrenContent(String contentId, String treeId, String contentPath, String catalog, String lang) 215 { 216 TreeConfiguration treeConfiguration = _getTreeConfiguration(treeId); 217 Content parentContent = _getParentContent(contentId); 218 Map<String, List<Content>> children = _getChildrenContent(parentContent, treeConfiguration, catalog, lang); 219 220 Map<String, Object> infos = new HashMap<>(); 221 222 List<Map<String, Object>> childrenInfos = new ArrayList<>(); 223 infos.put("children", childrenInfos); 224 225 boolean lastLevel = parentContent instanceof Course && !((Course) parentContent).hasCourseLists(); 226 227 for (String attributePath : children.keySet()) 228 { 229 for (Content childContent : children.get(attributePath)) 230 { 231 // Ignore course part not on last level 232 if (lastLevel || !(childContent instanceof CoursePart)) 233 { 234 Map<String, Object> childInfo = content2Json(childContent, contentPath + ModelItem.ITEM_PATH_SEPARATOR + childContent.getName()); 235 childInfo.put("metadataPath", attributePath); 236 237 if (!hasChildrenContent(childContent, treeConfiguration)) 238 { 239 childInfo.put("children", Collections.EMPTY_LIST); 240 } 241 242 childrenInfos.add(childInfo); 243 } 244 } 245 } 246 return infos; 247 } 248 249 /** 250 * Get the root node informations 251 * @param contentId The content 252 * @param catalog the catalog 253 * @param lang the lang 254 * @param overriddenData overridden data by the user 255 * @return The informations 256 */ 257 @Callable 258 public Map<String, Object> getRootNodeInformations(String contentId, String catalog, String lang, Map<String, Map<String, String>> overriddenData) 259 { 260 return getNodeInformations(contentId, catalog, lang, overriddenData); 261 } 262 263 /** 264 * Get the node informations 265 * @param contentId The content 266 * @param catalog the catalog 267 * @param lang the lang 268 * @param overriddenData Overridden data by the user 269 * @return The informations 270 */ 271 @Callable 272 public Map<String, Object> getNodeInformations(String contentId, String catalog, String lang, Map<String, Map<String, String>> overriddenData) 273 { 274 Content content = _ametysResolver.resolveById(contentId); 275 _launchCostComputation(content, catalog, lang, new OverriddenData(overriddenData)); 276 Map<String, Object> json = content2Json(content, content.getName()); 277 if (content instanceof Program) 278 { 279 json.put("lang", content.getLanguage()); 280 json.put("catalog", ((Program) content).getCatalog()); 281 } 282 return json; 283 } 284 285 /** 286 * Get the default JSON representation of a content of the tree 287 * @param content the content 288 * @param contentPath the content path 289 * @return the content as JSON 290 */ 291 protected Map<String, Object> content2Json(Content content, String contentPath) 292 { 293 Map<String, Object> infos = super.content2Json(content); 294 295 CostComputationData costData = _getCostData(); 296 ProgramItemData data = costData.get(content.getId()); 297 298 if (data != null && !(content instanceof CourseList)) 299 { 300 String realContentPath = 301 !(content instanceof OrgUnit) && _ametysResolver.query(QueryHelper.getXPathQuery(StringUtils.substringBefore(contentPath, ModelItem.ITEM_PATH_SEPARATOR), OrgUnitFactory.ORGUNIT_NODETYPE, null)).getSize() > 0 302 ? StringUtils.substringAfter(contentPath, ModelItem.ITEM_PATH_SEPARATOR) 303 : contentPath; 304 infos.putAll(_programItemData2json(data, realContentPath)); 305 } 306 307 return infos; 308 } 309 310 private Map<String, Object> _programItemData2json(ProgramItemData data, String contentPath) 311 { 312 Map<String, Object> json = new HashMap<>(); 313 json.put("effectives", _effectives2json(data.getEffectives(), contentPath)); 314 json.put("groups", _groups2json(data.getGroups())); 315 json.put("norm", _normDetails2json(data.getNormDetails())); 316 json.put("volumesOfHours", _volumesOfHours2json(data.getVolumesOfHours())); 317 json.put("eqTD", _eqTD2json(data.getEqTD(), contentPath)); 318 json.put("heRatio", data.getHeRatio()); 319 return json; 320 } 321 322 private Map<String, Object> _effectives2json(Effectives effectives, String contentPath) 323 { 324 Map<String, Object> json = new HashMap<>(); 325 if (effectives != null) 326 { 327 effectives.getOverriddenEffective().map(Double::longValue).ifPresent(eff -> json.put("overridden", eff)); 328 effectives.getGlobalEnteredEffective().map(Double::longValue).ifPresent(eff -> json.put("entered", eff)); 329 json.put("estimated", effectives.getEstimatedEffective().longValue()); 330 json.put("computed", effectives.getComputedEffective().longValue()); 331 332 // Prepare items to calculate local effectives 333 String parentPath = StringUtils.substringBeforeLast(contentPath, ModelItem.ITEM_PATH_SEPARATOR); 334 Double localEffectives = null; 335 336 // Fill the distribution 337 Map<String, Object> distribution = new TreeMap<>(); 338 Map<String, Double> estimatedEffectives = effectives.getEstimatedEffectiveByPath(); 339 for (String path : estimatedEffectives.keySet()) 340 { 341 String pathWithTitles = _getContentsFromPath(path) 342 .map(Content::getTitle) // Get the content title 343 .collect(Collectors.joining(" > ")); // Join the results with a ' > ' separator 344 distribution.put(pathWithTitles, estimatedEffectives.get(path)); 345 346 if (parentPath.endsWith(path)) 347 { 348 localEffectives = estimatedEffectives.get(path); 349 } 350 } 351 352 // If localEffectives not found 353 if (localEffectives == null) 354 { 355 // Number of cumulative occurrences, a shared element 3 times, should be divided by 3, 356 // then if it is shared several times in the path, it should be divided as well 357 Integer nbOccurrences = _getContentsFromPath(contentPath) 358 .map(this::_countParents) 359 .filter(size -> size > 0) 360 .reduce(1, (a, b) -> a * b); 361 362 localEffectives = effectives.getOverriddenEffective() 363 .or(() -> effectives.getGlobalEnteredEffective()) 364 .orElse(effectives.getEstimatedEffective()); 365 localEffectives = localEffectives / nbOccurrences; 366 } 367 json.put("local", localEffectives.longValue()); 368 369 json.put("distribution", distribution); 370 } 371 return json; 372 } 373 374 private Map<String, Object> _groups2json(Groups groups) 375 { 376 Map<String, Object> json = new HashMap<>(); 377 if (groups != null) 378 { 379 json.put("overridden", groups.getOverriddenGroups()); 380 json.put("entered", groups.getGroupsToOpen()); 381 json.put("computed", groups.getComputedGroups()); 382 } 383 return json; 384 } 385 386 private Map<String, Object> _normDetails2json(NormDetails normDetails) 387 { 388 Map<String, Object> json = new HashMap<>(); 389 if (normDetails != null) 390 { 391 json.put("effMax", normDetails.getEffectiveMax()); 392 json.put("effMinSup", normDetails.getEffectiveMinSup()); 393 json.put("label", normDetails.getNormLabel()); 394 } 395 return json; 396 } 397 398 private Map<String, Object> _volumesOfHours2json(VolumesOfHours volumesOfHours) 399 { 400 Map<String, Object> json = new HashMap<>(); 401 if (volumesOfHours != null) 402 { 403 json.put("original", volumesOfHours.getOriginalVolumeOfHours()); 404 json.putAll(volumesOfHours.getVolumes()); 405 } 406 return json; 407 } 408 409 private Map<String, Object> _eqTD2json(EqTD eqTD, String contentPath) 410 { 411 Map<String, Object> json = new HashMap<>(); 412 if (eqTD != null) 413 { 414 json.put("global", eqTD.getGlobalEqTD()); 415 json.put("local", eqTD.getLocalEqTD(contentPath)); 416 417 String parentPath = StringUtils.substringBeforeLast(contentPath, ModelItem.ITEM_PATH_SEPARATOR); 418 Double proratedEqTD = eqTD.getProratedEqTD() 419 .entrySet() 420 .stream() 421 .filter(e -> parentPath.endsWith(e.getKey())) 422 .map(Entry::getValue) 423 .reduce(0D, Double::sum); 424 json.put("prorated", proratedEqTD); 425 } 426 return json; 427 } 428 429 private Content _resolveSilently(String contentId) 430 { 431 try 432 { 433 return _ametysResolver.resolveById(contentId); 434 } 435 catch (UnknownAmetysObjectException e) 436 { 437 return null; 438 } 439 } 440 441 /** 442 * Launch the cost computation component algorithm with overridden data by the user 443 * @param contentsToRefresh all open contents in the tool to refresh 444 * @param contentId the root node 445 * @param catalog the catalog 446 * @param lang the lang 447 * @param overriddenData overridden data by the user 448 * @return new values associated with their path 449 */ 450 @Callable 451 public Map<String, Map<String, Object>> refresh(Map<String, String> contentsToRefresh, String contentId, String catalog, String lang, Map<String, Map<String, String>> overriddenData) 452 { 453 Map<String, Map<String, Object>> refreshedData = new HashMap<>(); 454 Content content = _ametysResolver.resolveById(contentId); 455 OverriddenData data = new OverriddenData(overriddenData); 456 457 _launchCostComputation(content, catalog, lang, data); 458 for (Entry<String, String> entry : contentsToRefresh.entrySet()) 459 { 460 Content item = _ametysResolver.resolveById(entry.getValue()); 461 refreshedData.put(entry.getKey(), content2Json(item, entry.getKey())); 462 } 463 return refreshedData; 464 } 465 466 /** 467 * Retrieve old data to compare with the new computed one 468 * @return all old data to compare 469 */ 470 @Callable 471 public Map<String, Object> getOldData() 472 { 473 Map<String, Object> data = new HashMap<>(); 474 475 CostComputationData costData = _getCostData(); 476 for (String itemId : costData.keySet()) 477 { 478 Map<String, Object> itemMap = new HashMap<>(); 479 ProgramItemData itemData = costData.get(itemId); 480 EqTD eqTD = itemData.getEqTD(); 481 if (eqTD != null) 482 { 483 itemMap.put("globalEqTD", eqTD.getGlobalEqTD()); 484 itemMap.put("proratedEqTD", eqTD.getProratedEqTD()); 485 itemMap.put("localEqTD", eqTD.getLocalEqTD()); 486 } 487 itemMap.put("heRatio", itemData.getHeRatio()); 488 data.put(itemId, itemMap); 489 } 490 491 return data; 492 } 493 494 /** 495 * Save overridden data to the contents 496 * @param overriddenData overridden data by the user 497 * @return true if the saving is successful 498 */ 499 @Callable 500 public Map<String, Object> saveOverriddenData(Map<String, Map<String, String>> overriddenData) 501 { 502 Map<String, Object> result = new HashMap<>(); 503 for (Entry<String, Map<String, String>> data : overriddenData.entrySet()) 504 { 505 String contentId = data.getKey(); 506 Map<String, String> contentValues = data.getValue(); 507 508 if (!contentValues.isEmpty()) 509 { 510 String status = null; 511 Map<String, String> json = new HashMap<>(); 512 json.put("id", contentId); 513 514 try 515 { 516 ModifiableDefaultContent content = _ametysResolver.resolveById(data.getKey()); 517 json.put("title", content.getTitle()); 518 if (content.hasValue("code")) 519 { 520 json.put("code", content.getValue("code")); 521 } 522 523 Map<String, Object> values = new HashMap<>(); 524 525 // If it is a course part 526 if (content instanceof CoursePart) 527 { 528 Optional.of("groups") 529 .map(contentValues::get) 530 .map(Long::parseLong) 531 .filter(v -> !Objects.equals(content.getValue("groupsToOpen"), v)) 532 .ifPresent(v -> values.put("groupsToOpen", v)); 533 534 Optional.of("nbHours") 535 .map(contentValues::get) 536 .map(Double::parseDouble) 537 .filter(v -> !Objects.equals(content.getValue("nbHours"), v)) 538 .ifPresent(v -> values.put("nbHours", v)); 539 } 540 // If it is a course list container 541 else if (content instanceof CourseListContainer) 542 { 543 Optional.of("effectivesGlobal") 544 .map(contentValues::get) 545 .map(Long::parseLong) 546 .filter(v -> !Objects.equals(content.getValue("numberOfStudentsEstimated"), v)) 547 .ifPresent(v -> values.put("numberOfStudentsEstimated", v)); 548 } 549 550 if (!values.isEmpty()) 551 { 552 _contentWorkflowHelper.editContent(content, values, _ACTION, View.of(content.getModel(), values.keySet().toArray(String[]::new))); 553 status = "updated"; 554 if (getLogger().isDebugEnabled()) 555 { 556 getLogger().debug("[SimulatorSaveData] Content {} has been updated from cost simulator", content.getTitle()); 557 } 558 } 559 } 560 catch (UnknownAmetysObjectException e) 561 { 562 status = "unknown"; 563 getLogger().warn("[SimulatorSaveData] Content '{}' doesn't exists anymore", contentId); 564 } 565 catch (InvalidActionException e) 566 { 567 status = "invalidAction"; 568 getLogger().warn("[SimulatorSaveData] Invalid edit action (could be a lack of rights) on content '{}'", contentId, e); 569 } 570 catch (WorkflowException e) 571 { 572 status = Optional.of(e) 573 .map(WorkflowException::getRootCause) 574 .filter(EditContentAccessDeniedException.class::isInstance) 575 .map(EditContentAccessDeniedException.class::cast) 576 .map( 577 cause -> 578 { 579 json.put("attributeLabel", _i18nUtils.translate(cause.getModelItem().getLabel())); 580 json.put("attributePath", cause.getModelItem().getPath()); 581 return "attributeRight"; 582 } 583 ) 584 .orElse("workflow"); 585 getLogger().warn("[SimulatorSaveData] Workflow problem on content '{}'", contentId, e); 586 } 587 catch (Exception e) 588 { 589 status = "error"; 590 getLogger().error("[SimulatorSaveData] Unknown error while updating content '{}'", contentId, e); 591 } 592 593 if (status != null) 594 { 595 @SuppressWarnings("unchecked") 596 List<Map<String, String>> jsonContents = (List<Map<String, String>>) result.computeIfAbsent(status, __ -> new ArrayList<>()); 597 jsonContents.add(json); 598 } 599 } 600 } 601 return result; 602 } 603 604 private Stream<Content> _getContentsFromPath(String path) 605 { 606 return Stream.of(path.split("/")) // Split the path 607 .sequential() // Take care of the order 608 .filter(StringUtils::isNotEmpty) // Remove empty names 609 .map(part -> QueryHelper.getXPathQuery(part, "ametys:content", null)) // Build the query to get the content with the current name 610 .map(_ametysResolver::<Content>query) // Run the query 611 .map(AmetysObjectIterable::stream) // Stream the results 612 .map(Stream::findFirst) // Keep only the first result, it shouldn't have more than one 613 .filter(Optional::isPresent) // Ignore unexisting elements, it shouldn't happen 614 .map(Optional::get); // Get the optional element 615 } 616 617 private Integer _countParents(Content content) 618 { 619 if (content instanceof ProgramItem programItem) 620 { 621 return _odfHelper.getParentProgramItems(programItem).size(); 622 } 623 624 if (content instanceof CoursePart coursePart) 625 { 626 return coursePart.getCourses().size(); 627 } 628 629 return 0; 630 } 631}