001/* 002 * Copyright 2021 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.odfsync.pegase.scc; 018 019import java.io.IOException; 020import java.math.BigDecimal; 021import java.util.ArrayList; 022import java.util.Collection; 023import java.util.HashMap; 024import java.util.HashSet; 025import java.util.LinkedHashMap; 026import java.util.LinkedHashSet; 027import java.util.List; 028import java.util.Map; 029import java.util.Optional; 030import java.util.Set; 031import java.util.function.Predicate; 032import java.util.stream.Collectors; 033import java.util.stream.Stream; 034 035import org.apache.avalon.framework.configuration.Configuration; 036import org.apache.avalon.framework.configuration.ConfigurationException; 037import org.apache.avalon.framework.service.ServiceException; 038import org.apache.avalon.framework.service.ServiceManager; 039import org.apache.commons.lang3.StringUtils; 040import org.slf4j.Logger; 041 042import org.ametys.cms.data.ContentValue; 043import org.ametys.cms.repository.Content; 044import org.ametys.cms.repository.ContentQueryHelper; 045import org.ametys.cms.repository.LanguageExpression; 046import org.ametys.cms.repository.ModifiableContent; 047import org.ametys.cms.repository.WorkflowAwareContent; 048import org.ametys.core.util.JSONUtils; 049import org.ametys.odf.ProgramItem; 050import org.ametys.odf.catalog.CatalogsManager; 051import org.ametys.odf.course.Course; 052import org.ametys.odf.course.CourseFactory; 053import org.ametys.odf.courselist.CourseList; 054import org.ametys.odf.courselist.CourseListFactory; 055import org.ametys.odf.program.ContainerFactory; 056import org.ametys.odf.program.ProgramFactory; 057import org.ametys.odf.program.ProgramPart; 058import org.ametys.odf.program.SubProgramFactory; 059import org.ametys.odf.program.TraversableProgramPart; 060import org.ametys.odf.workflow.AbstractCreateODFContentFunction; 061import org.ametys.plugins.contentio.synchronize.AbstractSimpleSynchronizableContentsCollection; 062import org.ametys.plugins.odfsync.pegase.ws.PegaseApiManager; 063import org.ametys.plugins.odfsync.utils.ContentWorkflowDescription; 064import org.ametys.plugins.repository.AmetysObjectIterable; 065import org.ametys.plugins.repository.jcr.NameHelper; 066import org.ametys.plugins.repository.query.expression.AndExpression; 067import org.ametys.plugins.repository.query.expression.Expression; 068import org.ametys.plugins.repository.query.expression.Expression.Operator; 069import org.ametys.plugins.repository.query.expression.StringExpression; 070import org.ametys.runtime.config.Config; 071import org.ametys.runtime.i18n.I18nizableText; 072import org.ametys.runtime.model.View; 073 074import com.opensymphony.workflow.WorkflowException; 075 076import fr.pcscol.pegase.cof.ApiException; 077import fr.pcscol.pegase.cof.api.ArbresApi; 078import fr.pcscol.pegase.cof.api.ObjetsMaquetteApi; 079import fr.pcscol.pegase.cof.model.Arbre; 080import fr.pcscol.pegase.cof.model.Enfant; 081import fr.pcscol.pegase.cof.model.ObjetLibelle; 082import fr.pcscol.pegase.cof.model.ObjetMaquette; 083import fr.pcscol.pegase.cof.model.ObjetMaquetteDetails; 084import fr.pcscol.pegase.cof.model.ObjetMaquetteFormation; 085import fr.pcscol.pegase.cof.model.ObjetMaquetteFormationTypeFormation; 086import fr.pcscol.pegase.cof.model.ObjetMaquetteGroupement; 087import fr.pcscol.pegase.cof.model.Pageable; 088import fr.pcscol.pegase.cof.model.PagedObjetMaquette; 089import fr.pcscol.pegase.cof.model.Ref; 090 091/** 092 * SCC for Pegase (COF). 093 */ 094public class PegaseSynchronizableContentsCollection extends AbstractSimpleSynchronizableContentsCollection 095{ 096 private static final String __INSERTED_GROUPEMENT_SUFFIX = "-LIST"; 097 private static final int __PEGASE_SYNC_CODE_MAX_LENGTH = 25; 098 099 /** The JSON utils */ 100 protected JSONUtils _jsonUtils; 101 102 /** The catalogs manager */ 103 protected CatalogsManager _catalogsManager; 104 105 /** The Pégase API manager */ 106 protected PegaseApiManager _pegaseApiManager; 107 108 /** Mapping between metadata and columns */ 109 protected Map<String, Map<String, List<String>>> _mappingByContentType; 110 111 /** Search fields to display */ 112 protected Set<String> _searchFields; 113 114 /** List of imported contents */ 115 protected Map<String, Integer> _importedContents; 116 117 /** List of synchronized contents having differences */ 118 protected Set<String> _synchronizedContents; 119 120 /** List of updated contents by relation */ 121 protected Set<String> _updatedRelationContents; 122 123 /** Map to link contents to its children at the end of the process */ 124 protected Map<String, Set<String>> _contentsChildren; 125 126 /** Default language configured for ODF */ 127 protected String _odfLang; 128 129 /** The structure code for Pégase */ 130 protected String _structureCode; 131 132 @Override 133 public void service(ServiceManager manager) throws ServiceException 134 { 135 super.service(manager); 136 _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE); 137 _catalogsManager = (CatalogsManager) manager.lookup(CatalogsManager.ROLE); 138 _pegaseApiManager = (PegaseApiManager) manager.lookup(PegaseApiManager.ROLE); 139 } 140 141 @Override 142 public String getIdField() 143 { 144 return "pegaseSyncCode"; 145 } 146 147 /** 148 * Get the identifier JSON field. 149 * @return the column id 150 */ 151 protected String getIdColumn() 152 { 153 return "id"; 154 } 155 156 @SuppressWarnings("unchecked") 157 @Override 158 public Set<String> getLocalAndExternalFields(Map<String, Object> additionalParameters) 159 { 160 return Optional.ofNullable(additionalParameters) 161 .map(params -> params.get("contentTypes")) 162 .filter(List.class::isInstance) 163 .map(cTypes -> (List<String>) cTypes) 164 .filter(Predicate.not(List::isEmpty)) 165 .map(l -> l.get(0)) 166 .map(_mappingByContentType::get) 167 .map(Map::keySet) 168 .orElse(Set.of()); 169 } 170 171 @Override 172 protected Map<String, Object> putIdParameter(String idValue) 173 { 174 Map<String, Object> parameters = new HashMap<>(); 175 parameters.put(getIdColumn(), List.of(idValue)); 176 return parameters; 177 } 178 179 @SuppressWarnings("unchecked") 180 @Override 181 protected Map<String, Map<String, Object>> internalSearch(Map<String, Object> parameters, int offset, int limit, List<Object> sort, Logger logger) 182 { 183 Map<String, Map<String, Object>> results = new LinkedHashMap<>(); 184 185 try 186 { 187 ObjetsMaquetteApi objetsMaquetteApi = _pegaseApiManager.getObjetsMaquetteApi(); 188 ArbresApi arbresApi = _pegaseApiManager.getArbresApi(); 189 190 boolean importMode = (boolean) parameters.getOrDefault("import", false); 191 List<String> pegaseSyncCodeValues = (List<String>) parameters.getOrDefault(getIdColumn(), new ArrayList<>()); 192 193 if (importMode) 194 { 195 Map<String, String> idValues = new HashMap<>(); 196 if (!pegaseSyncCodeValues.isEmpty()) 197 { 198 for (String pegaseSyncCode : pegaseSyncCodeValues) 199 { 200 // Get details for each requested programs by objetsMaquetteApi 201 ObjetMaquette programDetails = _getProgramDetails(pegaseSyncCode, objetsMaquetteApi); 202 idValues.put(programDetails.getId(), programDetails.getCode()); 203 } 204 } 205 else 206 { 207 // Get details for all requested programs by objetsMaquetteApi 208 PagedObjetMaquette pagedPrograms = _getPagedProgramsDetails(parameters, offset, limit, sort, objetsMaquetteApi); 209 for (ObjetMaquette programDetails : pagedPrograms.getItems()) 210 { 211 idValues.put(programDetails.getId(), programDetails.getCode()); 212 } 213 } 214 215 results.putAll(_getObjectDetailsForImport(idValues, objetsMaquetteApi, arbresApi, logger)); 216 } 217 else if (pegaseSyncCodeValues.isEmpty()) 218 { 219 // Get details for all requested programs by objetsMaquetteApi 220 PagedObjetMaquette pagedPrograms = _getPagedProgramsDetails(parameters, offset, limit, sort, objetsMaquetteApi); 221 for (ObjetMaquette programDetails : pagedPrograms.getItems()) 222 { 223 String pegaseSyncCode = _createPegaseSyncCode(programDetails); 224 225 // If not an import, fill the results with Pégase synchronization code 226 Map<String, Object> result = results.computeIfAbsent(pegaseSyncCode, __ -> new HashMap<>()); 227 Map<String, String> fields = _getFields(programDetails); 228 229 for (String keptField : _searchFields) 230 { 231 String field = fields.get(keptField); 232 result.put(keptField, field); 233 } 234 235 result.put(SCC_UNIQUE_ID, pegaseSyncCode); 236 } 237 } 238 } 239 catch (ApiException | IOException e) 240 { 241 throw new RuntimeException("Error while getting remote values", e); 242 } 243 244 return results; 245 } 246 247 private ObjetMaquette _getProgramDetails(String pegaseSyncCode, ObjetsMaquetteApi objetsMaquetteApi) throws ApiException 248 { 249 String[] split = pegaseSyncCode.split("/"); 250 251 if (split.length != 2) 252 { 253 // If the given code is not well formatted, throw an exception 254 throw new ApiException("Le code de synchronisation n'est pas valide, il doit être de la forme [code]/[version] mais il a comme valeur : " + pegaseSyncCode); 255 } 256 257 String code = split[0]; 258 BigDecimal version = new BigDecimal(split[1]); 259 260 PagedObjetMaquette pagedObjectDetails = objetsMaquetteApi.lireListeObjetsMaquette(_structureCode, "FORMATION", null, null, code, null, null, null, null, null, null, version, null, null, null, null, null, null, null); 261 262 if (pagedObjectDetails != null) 263 { 264 List<ObjetMaquette> items = pagedObjectDetails.getItems(); 265 if (items != null && !items.isEmpty()) 266 { 267 // Retrieve first found formation 268 return items.get(0); 269 } 270 } 271 272 // If no formation has been found with the given code, throw an exception 273 throw new ApiException("Aucune formation n'a été trouvée avec le code de synchronisation " + pegaseSyncCode); 274 } 275 276 @SuppressWarnings("unchecked") 277 private Map<String, Map<String, Object>> _getObjectDetailsForImport(Map<String, String> idValues, ObjetsMaquetteApi objetsMaquetteApi, ArbresApi arbresApi, Logger logger) throws ApiException 278 { 279 Map<String, Map<String, Object>> results = new LinkedHashMap<>(); 280 Set<String> handledObjects = new HashSet<>(); 281 282 for (String idValue : idValues.keySet()) 283 { 284 // Get the program tree structure from the idValue 285 Arbre tree = arbresApi.lireArbre(_structureCode, idValue); 286 287 // Get the program's details from the tree structure 288 String code = idValues.get(idValue); 289 ObjetMaquette programDetails = tree.getObjetsMaquette().get(code); 290 291 if (programDetails != null) 292 { 293 List<ObjetMaquette> children = new ArrayList<>(); 294 children.add(programDetails); 295 while (!children.isEmpty()) 296 { 297 ObjetMaquette child = children.remove(0); 298 299 String childCode = _createPegaseSyncCode(child); 300 if (handledObjects.add(childCode)) 301 { 302 // Get the current child's children 303 Collection<Enfant> grandChildren = child.getEnfants().values(); 304 if (!grandChildren.isEmpty()) 305 { 306 try 307 { 308 // Children must be read from objets-maquette API because they do not keep the right order in the tree 309 // Call the objets-maquette API only if the object has children (detected in the tree) 310 grandChildren = objetsMaquetteApi.lireEnfants(_structureCode, child.getId()); 311 } 312 catch (Exception e) 313 { 314 // If the request doesn't work, the ObjetMaquette is a GROUPEMENT that we created 315 // Do nothing to keep children that have been added in the right order 316 } 317 } 318 319 Map<String, Collection<? extends Object>> childResult = _objectDetailsToMap(child, grandChildren, tree, logger); 320 children.addAll((List<ObjetMaquette>) childResult.remove("childrenToSend")); 321 322 // Add mapped object to the results 323 results.put((String) childResult.remove(getIdField()).iterator().next(), (Map<String, Object>) (Object) childResult); 324 } 325 } 326 } 327 } 328 329 return results; 330 } 331 332 private String _createPegaseSyncCode(ObjetMaquette objectDetails) 333 { 334 String code = objectDetails.getCode(); 335 336 if ("FORMATION".equals(objectDetails.getType().getLibelle())) 337 { 338 String version = objectDetails.getDetail().getFormation().getVersion().toString(); 339 340 code += "/" + version; 341 } 342 343 return code; 344 } 345 346 @Override 347 public List<ModifiableContent> importContent(String idValue, Map<String, Object> importParams, Logger logger) throws Exception 348 { 349 Map<String, Object> parameters = putIdParameter(idValue); 350 return _importOrSynchronizeContents(parameters, true, logger); 351 } 352 353 @Override 354 public void synchronizeContent(ModifiableContent content, Logger logger) throws Exception 355 { 356 String idValue = content.getValue(getIdField()); 357 Map<String, Object> parameters = putIdParameter(idValue); 358 _importOrSynchronizeContents(parameters, true, logger); 359 } 360 361 @Override 362 protected List<ModifiableContent> _importOrSynchronizeContents(Map<String, Object> searchParams, boolean forceImport, Logger logger) 363 { 364 _importedContents = new HashMap<>(); 365 _synchronizedContents = new HashSet<>(); 366 _updatedRelationContents = new HashSet<>(); 367 _contentsChildren = new HashMap<>(); 368 369 List<ModifiableContent> contents = super._importOrSynchronizeContents(searchParams, forceImport, logger); 370 _updateRelations(logger); 371 _updateWorkflowStatus(logger); 372 373 _importedContents = null; 374 _synchronizedContents = null; 375 _updatedRelationContents = null; 376 _contentsChildren = null; 377 378 return contents; 379 } 380 381 private void _updateRelations(Logger logger) 382 { 383 for (String contentId : _contentsChildren.keySet()) 384 { 385 WorkflowAwareContent content = _resolver.resolveById(contentId); 386 Set<String> childrenCodes = _contentsChildren.get(contentId); 387 String contentLanguage = content.getLanguage(); 388 String contentCatalog = content.getValue("catalog"); 389 Map<String, Set<String>> childrenByAttributeName = new HashMap<>(); 390 391 for (String childCode : childrenCodes) 392 { 393 Expression expression = new AndExpression( 394 _sccHelper.getCollectionExpression(getId()), 395 new StringExpression(getIdField(), Operator.EQ, childCode), 396 new StringExpression("catalog", Operator.EQ, contentCatalog), 397 new LanguageExpression(Operator.EQ, contentLanguage) 398 ); 399 400 ModifiableContent childContent = _resolver.<ModifiableContent>query(ContentQueryHelper.getContentXPathQuery(expression)) 401 .stream() 402 .findFirst() 403 .orElse(null); 404 405 if (childContent == null) 406 { 407 logger.warn("Content with code '{}' in {} on catalog '{}' was not found in the repository to update relations with content [{}] '{}' ({}).", childCode, contentLanguage, contentCatalog, content.getValue(getIdField()), content.getTitle(), contentCatalog); 408 } 409 else 410 { 411 String attributesName = _getChildAttributeName(content, childContent); 412 if (attributesName != null) 413 { 414 Set<String> children = childrenByAttributeName.computeIfAbsent(attributesName, __ -> new LinkedHashSet<>()); 415 children.add(childContent.getId()); 416 } 417 else 418 { 419 logger.warn("The child content [{}] '{}' of type '{}' is not compatible with parent content [{}] '{}' of type '{}'.", childCode, childContent.getTitle(), childContent.getTypes()[0], content.getValue(getIdField()), content.getTitle(), content.getTypes()[0]); 420 } 421 } 422 } 423 _updateRelations(content, childrenByAttributeName, logger); 424 } 425 } 426 427 private String _getChildAttributeName(Content parentContent, Content childContent) 428 { 429 if (childContent instanceof Course && parentContent instanceof CourseList) 430 { 431 return CourseList.CHILD_COURSES; 432 } 433 434 if (parentContent instanceof Course && childContent instanceof CourseList) 435 { 436 return Course.CHILD_COURSE_LISTS; 437 } 438 439 if (parentContent instanceof TraversableProgramPart && childContent instanceof ProgramPart) 440 { 441 return TraversableProgramPart.CHILD_PROGRAM_PARTS; 442 } 443 444 return null; 445 } 446 447 private void _updateRelations(WorkflowAwareContent content, Map<String, Set<String>> contentRelationsByAttribute, Logger logger) 448 { 449 // Compute the view 450 View view = View.of(content.getModel(), contentRelationsByAttribute.keySet().toArray(new String[contentRelationsByAttribute.size()])); 451 452 // Compute values 453 Map<String, Object> values = new HashMap<>(); 454 for (String attributeName : contentRelationsByAttribute.keySet()) 455 { 456 // Add the content relations to the existing ones 457 List<String> attributeValue = _getContentAttributeValue(content, attributeName); 458 contentRelationsByAttribute.get(attributeName) 459 .stream() 460 .filter(id -> !attributeValue.contains(id)) 461 .forEach(attributeValue::add); 462 463 values.put(attributeName, attributeValue.toArray(new String[attributeValue.size()])); 464 } 465 466 // Compute not synchronized contents 467 Set<String> notSynchronizedContentIds = contentRelationsByAttribute.values() 468 .stream() 469 .flatMap(Collection::stream) 470 .filter(id -> !_synchronizedContents.contains(id)) 471 .collect(Collectors.toSet()); 472 473 try 474 { 475 _editContent(content, Optional.of(view), values, Map.of(), false, notSynchronizedContentIds, logger); 476 } 477 catch (WorkflowException e) 478 { 479 _nbError++; 480 logger.error("The content '{}' cannot be links edited (workflow action)", content, e); 481 } 482 } 483 484 private List<String> _getContentAttributeValue(Content content, String attributeName) 485 { 486 return Optional.of(attributeName) 487 .map(content::<ContentValue[]>getValue) 488 .map(Stream::of) 489 .orElseGet(Stream::empty) 490 .map(ContentValue::getContentId) 491 .collect(Collectors.toList()); 492 } 493 494 private void _updateWorkflowStatus(Logger logger) 495 { 496 // Validate contents -> only on newly imported contents 497 if (validateAfterImport()) 498 { 499 for (String contentId : _importedContents.keySet()) 500 { 501 WorkflowAwareContent content = _resolver.resolveById(contentId); 502 Integer validationActionId = _importedContents.get(contentId); 503 if (validationActionId > 0) 504 { 505 validateContent(content, validationActionId, logger); 506 } 507 } 508 } 509 } 510 511 /** 512 * Get the workflow description for the given category. 513 * @param category The category 514 * @return the matching workflow description 515 * @throws IllegalArgumentException if the given categorie does not match any content workflow 516 */ 517 protected ContentWorkflowDescription _mapWorkflowDescription(String category) throws IllegalArgumentException 518 { 519 switch (category) 520 { 521 case "formation": 522 return ContentWorkflowDescription.PROGRAM_WF_DESCRIPTION; 523 case "OO": 524 return ContentWorkflowDescription.SUBPROGRAM_WF_DESCRIPTION; 525 case "OTT": 526 return ContentWorkflowDescription.CONTAINER_WF_DESCRIPTION; 527 case "GROUPEMENT": 528 return ContentWorkflowDescription.COURSELIST_WF_DESCRIPTION; 529 case "OP": 530 return ContentWorkflowDescription.COURSE_WF_DESCRIPTION; 531 default: 532 throw new IllegalArgumentException("The category '" + category + "' cannot be converted to an Ametys content type."); 533 } 534 } 535 536 @SuppressWarnings("unchecked") 537 @Override 538 protected Map<String, Map<String, List<Object>>> getRemoteValues(Map<String, Object> parameters, Logger logger) 539 { 540 return (Map<String, Map<String, List<Object>>>) (Object) internalSearch(parameters, 0, Integer.MAX_VALUE, null, logger); 541 } 542 543 @Override 544 protected void configureDataSource(Configuration configuration) throws ConfigurationException 545 { 546 _odfLang = Config.getInstance().getValue("odf.programs.lang"); 547 _searchFields = new HashSet<>(); 548 _mappingByContentType = new HashMap<>(); 549 550 if (Config.getInstance().getValue("pegase.activate", true, false)) 551 { 552 _structureCode = Config.getInstance().getValue("pegase.structure.code"); 553 554 // Champs affichés dans la recherche 555 _searchFields.add("code"); 556 _searchFields.add("libelleCourt"); 557 _searchFields.add("libelleLong"); 558 _searchFields.add("version"); 559 _searchFields.add("id"); 560 _searchFields.add("codeTypeDiplome"); 561 _searchFields.add("codeNiveauFormation"); 562 _searchFields.add("codeDomaineFormation"); 563 _searchFields.add("codeNatureDiplome"); 564 _searchFields.add("codeNiveauDiplome"); 565 _searchFields.add("codeMention"); 566 _searchFields.add("codeChampFormation"); 567 _searchFields.add("ects"); 568 569 570 // Program 571 Map<String, List<String>> contentTypeMapping = new HashMap<>(); 572 contentTypeMapping.put("title", List.of("libelleLong", "libelleCourt")); 573 contentTypeMapping.put("ects", List.of("ects")); 574 contentTypeMapping.put("educationKind", List.of("codeNatureDiplomeCode")); 575 contentTypeMapping.put("presentation", List.of("description")); 576 contentTypeMapping.put("mention", List.of("codeMention")); 577 contentTypeMapping.put("domain", List.of("codeDomaineFormation")); 578 contentTypeMapping.put("programField", List.of("codeChampFormation")); 579 contentTypeMapping.put("educationLevel", List.of("codeNiveauFormation")); 580 contentTypeMapping.put("rncpLevel", List.of("codeNiveauDiplome")); 581 contentTypeMapping.put("degree", List.of("codeTypeDiplome")); 582 _mappingByContentType.put(ProgramFactory.PROGRAM_CONTENT_TYPE, contentTypeMapping); 583 584 // SubProgram 585 contentTypeMapping = new HashMap<>(); 586 contentTypeMapping.put("title", List.of("libelleLong", "libelleCourt")); 587 contentTypeMapping.put("ects", List.of("ects")); 588 contentTypeMapping.put("presentation", List.of("description")); 589 _mappingByContentType.put(SubProgramFactory.SUBPROGRAM_CONTENT_TYPE, contentTypeMapping); 590 591 // Container 592 contentTypeMapping = new HashMap<>(); 593 contentTypeMapping.put("title", List.of("libelleLong", "libelleCourt")); 594 contentTypeMapping.put("ects", List.of("ects")); 595 contentTypeMapping.put("description", List.of("description")); 596 contentTypeMapping.put("nature", List.of("type")); 597 _mappingByContentType.put(ContainerFactory.CONTAINER_CONTENT_TYPE, contentTypeMapping); 598 599 // CourseList 600 contentTypeMapping = new HashMap<>(); 601 contentTypeMapping.put("title", List.of("libelleLong", "libelleCourt")); 602 contentTypeMapping.put("plageDeChoix", List.of("plageDeChoix")); 603 contentTypeMapping.put("obligatoire", List.of("obligatoire")); 604 contentTypeMapping.put("min", List.of("plageMin")); 605 contentTypeMapping.put("max", List.of("plageMax")); 606 _mappingByContentType.put(CourseListFactory.COURSE_LIST_CONTENT_TYPE, contentTypeMapping); 607 608 // Course 609 contentTypeMapping = new HashMap<>(); 610 contentTypeMapping.put("title", List.of("libelleLong", "libelleCourt")); 611 contentTypeMapping.put("ects", List.of("ects")); 612 contentTypeMapping.put("description", List.of("description")); 613 contentTypeMapping.put("courseType", List.of("type")); 614 _mappingByContentType.put(CourseFactory.COURSE_CONTENT_TYPE, contentTypeMapping); 615 } 616 } 617 618 @Override 619 protected void configureSearchModel() 620 { 621 List<String> sortableColumns = List.of("code", "libelleCourt", "version"); 622 623 _searchModelConfiguration.addCriterion("libelle", new I18nizableText("plugin.odf-sync", "PLUGINS_ODF_SYNC_PEGASE_CRITERION_LIBELLE"), "string"); 624 _searchModelConfiguration.addCriterion("code", new I18nizableText("plugin.odf-sync", "PLUGINS_ODF_SYNC_PEGASE_CRITERION_CODE"), "string"); 625 _searchModelConfiguration.addCriterion("version", new I18nizableText("plugin.odf-sync", "PLUGINS_ODF_SYNC_PEGASE_CRITERION_VERSION"), "string"); 626 _searchModelConfiguration.addCriterion("validee", new I18nizableText("plugin.odf-sync", "PLUGINS_ODF_SYNC_PEGASE_CRITERION_VALIDEE"), "boolean", "edition.boolean-combobox"); 627 628 for (String keptField : _searchFields) 629 { 630 if (sortableColumns.contains(keptField)) 631 { 632 _searchModelConfiguration.addColumn(keptField, new I18nizableText(keptField), true); 633 } 634 else 635 { 636 _searchModelConfiguration.addColumn(keptField, new I18nizableText(keptField), false); 637 } 638 } 639 } 640 641 /** 642 * Get the catalog for import. 643 * @return the catalog 644 */ 645 protected String getCatalog() 646 { 647 return Optional.of(getParameterValues()) 648 .map(params -> params.get("catalog")) 649 .map(String.class::cast) 650 .filter(StringUtils::isNotBlank) 651 .orElseGet(() -> _catalogsManager.getDefaultCatalogName()); 652 } 653 654 /** 655 * Get or create the content defined by the given parameters. 656 * @param lang The content language 657 * @param idValue The synchronization code 658 * @param remoteValues The remote values 659 * @param forceImport <code>true</code> to force import (only on single import or unlimited global synchronization) 660 * @param logger The logger 661 * @return the content 662 * @throws Exception if an error occurs 663 */ 664 protected ModifiableContent _getOrCreateContent(String lang, String idValue, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger) throws Exception 665 { 666 ContentWorkflowDescription wfDescription = (ContentWorkflowDescription) remoteValues.get("workflowDescription").get(0); 667 ModifiableContent content = _getContent(lang, idValue, wfDescription.getContentType()); 668 if (content == null && (forceImport || !synchronizeExistingContentsOnly())) 669 { 670 // Calculate contentTitle 671 String contentTitle = Optional.of(remoteValues) 672 .map(v -> v.get("title")) 673 .map(List::stream) 674 .orElseGet(Stream::empty) 675 .filter(String.class::isInstance) 676 .map(String.class::cast) 677 .filter(StringUtils::isNotEmpty) 678 .findFirst() 679 .orElse(idValue); 680 681 String contentName = NameHelper.filterName(_contentPrefix + "-" + contentTitle + "-" + lang); 682 683 Map<String, Object> inputs = new HashMap<>(); 684 String catalog = getCatalog(); 685 if (catalog != null) 686 { 687 inputs.put(AbstractCreateODFContentFunction.CONTENT_CATALOG_KEY, catalog); 688 } 689 690 Map<String, Object> resultMap = _contentWorkflowHelper.createContent( 691 wfDescription.getWorkflowName(), 692 wfDescription.getInitialActionId(), 693 contentName, 694 contentTitle, 695 new String[] {wfDescription.getContentType()}, 696 null, 697 lang, 698 null, 699 null, 700 inputs); 701 702 if ((boolean) resultMap.getOrDefault("error", false)) 703 { 704 _nbError++; 705 } 706 707 content = (ModifiableContent) resultMap.get(Content.class.getName()); 708 709 if (content != null) 710 { 711 _sccHelper.updateSCCProperty(content, getId()); 712 713 // Set sync code 714 content.setValue(getIdField(), idValue); 715 716 content.saveChanges(); 717 _importedContents.put(content.getId(), wfDescription.getValidationActionId()); 718 _nbCreatedContents++; 719 } 720 } 721 return content; 722 } 723 724 /** 725 * Get the content from the synchronization code, the lang, the catalog and the content type. 726 * @param lang The lang 727 * @param syncCode The synchronization code 728 * @param contentType The content type 729 * @return the retrieved content 730 */ 731 protected ModifiableContent _getContent(String lang, String syncCode, String contentType) 732 { 733 List<Expression> expList = _getExpressionsList(lang, syncCode, contentType); 734 AndExpression andExp = new AndExpression(expList.toArray(new Expression[expList.size()])); 735 String xPathQuery = ContentQueryHelper.getContentXPathQuery(andExp); 736 737 AmetysObjectIterable<ModifiableContent> contents = _resolver.query(xPathQuery); 738 739 if (contents.getSize() > 0) 740 { 741 return contents.iterator().next(); 742 } 743 744 return null; 745 } 746 747 @Override 748 protected Optional<ModifiableContent> _importOrSynchronizeContent(String idValue, String lang, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger) 749 { 750 try 751 { 752 ModifiableContent content = _getOrCreateContent(lang, idValue, remoteValues, forceImport, logger); 753 if (content != null) 754 { 755 return Optional.of(_synchronizeContent(content, remoteValues, logger)); 756 } 757 } 758 catch (Exception e) 759 { 760 _nbError++; 761 logger.error("An error occurred while importing or synchronizing content", e); 762 } 763 764 return Optional.empty(); 765 } 766 767 @SuppressWarnings("unchecked") 768 @Override 769 protected ModifiableContent _synchronizeContent(ModifiableContent content, Map<String, List<Object>> remoteValues, Logger logger) throws Exception 770 { 771 super._synchronizeContent(content, remoteValues, logger); 772 // Add children to the list to handle later to add relations 773 if (remoteValues.containsKey("children")) 774 { 775 Set<String> children = _contentsChildren.computeIfAbsent(content.getId(), __ -> new LinkedHashSet<>()); 776 children.addAll((List<String>) (Object) remoteValues.get("children")); 777 } 778 return content; 779 } 780 781 @Override 782 protected boolean _fillContent(Map<String, List<Object>> remoteValues, ModifiableContent content, Map<String, Object> additionalParameters, boolean create, Logger logger) throws Exception 783 { 784 _synchronizedContents.add(content.getId()); 785 return super._fillContent(remoteValues, content, additionalParameters, create, logger); 786 } 787 788 @Override 789 public List<String> getLanguages() 790 { 791 return List.of(_odfLang); 792 } 793 794 @Override 795 protected List<Expression> _getExpressionsList(String lang, String idValue, String contentType) 796 { 797 List<Expression> expList = super._getExpressionsList(lang, idValue, contentType); 798 String catalog = getCatalog(); 799 if (catalog != null) 800 { 801 expList.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog)); 802 } 803 return expList; 804 } 805 806 @Override 807 protected Map<String, Map<String, List<Object>>> getTransformedRemoteValues(Map<String, Object> searchParameters, Logger logger) 808 { 809 searchParameters.put("import", true); 810 return super.getTransformedRemoteValues(searchParameters, logger); 811 } 812 813 @Override 814 protected Map<String, Object> _transformRemoteValuesCardinality(Map<String, List<Object>> remoteValues, String obsoleteContentTypeId) 815 { 816 String realContentTypeId = Optional.of(remoteValues) 817 .map(v -> v.get("workflowDescription")) 818 .map(l -> l.get(0)) 819 .map(ContentWorkflowDescription.class::cast) 820 .map(ContentWorkflowDescription::getContentType) 821 .orElse(null); 822 return super._transformRemoteValuesCardinality(remoteValues, realContentTypeId); 823 } 824 825 private PagedObjetMaquette _getPagedProgramsDetails(Map<String, Object> parameters, int offset, int limit, List<Object> sort, ObjetsMaquetteApi objetsMaquetteApi) throws ApiException 826 { 827 PagedObjetMaquette pagedPrograms = null; 828 Pageable pageable = new Pageable(); 829 int pageNumber = offset / 50; 830 pageable.setPage(pageNumber); 831 pageable.setTaille(limit); 832 833 if (sort == null) 834 { 835 pageable.setTri(List.of()); 836 } 837 else 838 { 839 // Convert the sort parameter from Object to JSON 840 String jsonSortParameters = _jsonUtils.convertObjectToJson(sort.get(0)); 841 // Convert the sort parameter from JSON to Map<String, Object> 842 Map<String, Object> sortParameters = _jsonUtils.convertJsonToMap(jsonSortParameters); 843 // Create the list containing the sort result; it is going to be of the form : ["field,direction"] 844 List<String> sortParametersArray = new ArrayList<>(); 845 // Create the sort parameter that is going to be sent in the list; it is going to be of the form : "field,direction" 846 StringBuilder stringBuilder = new StringBuilder(); 847 // Get the parameter "property" which is the column on which the sorting is meant to be made 848 String property = (String) sortParameters.get("property"); 849 850 if (!"code".equals(property) & !"libelle".equals(property) & !"version".equals(property)) 851 { 852 if ("libelleCourt".equals(property)) 853 { 854 stringBuilder.append("libelle,").append((String) sortParameters.get("direction")); 855 sortParametersArray.add(stringBuilder.toString()); 856 } 857 } 858 else 859 { 860 stringBuilder.append(property).append(",").append((String) sortParameters.get("direction")); 861 sortParametersArray.add(stringBuilder.toString()); 862 } 863 864 pageable.setTri(sortParametersArray); 865 } 866 867 // Get the PagedObjetsMaquette : paged program with potential search parameters 868 Object versionO = parameters.get("version"); 869 BigDecimal version = null; 870 871 if (versionO != null) 872 { 873 version = new BigDecimal(versionO.toString()); 874 } 875 876 String status = null; 877 Object value = parameters.get("validee"); 878 if (value != null) 879 { 880 status = (boolean) parameters.get("validee") ? "VALIDE" : "EN-CONSTRUCTION"; 881 } 882 883 pagedPrograms = objetsMaquetteApi.lireListeObjetsMaquette(_structureCode, "FORMATION", null, null, _getParametersInRegularExp("code", parameters), _getParametersInRegularExp("libelle", parameters), null, null, null, null, null, version, status, null, null, null, null, null, pageable); 884 return pagedPrograms; 885 } 886 887 private String _getParametersInRegularExp(String field, Map<String, Object> parameters) 888 { 889 Object value = parameters.get(field); 890 if (value != null) 891 { 892 StringBuilder criteria = new StringBuilder(); 893 criteria.append("*").append(value.toString()).append("*"); 894 return criteria.toString(); 895 } 896 897 return null; 898 } 899 900 private Map<String, List<? extends Object>> _getObjectFields(ObjetMaquette objectDetails, String categorie) 901 { 902 Map<String, List<? extends Object>> result = new HashMap<>(); 903 904 ContentWorkflowDescription wfDescription = _mapWorkflowDescription(categorie); 905 result.put("workflowDescription", List.of(wfDescription)); 906 907 String contentTypeId = wfDescription.getContentType(); 908 Map<String, List<String>> contentTypeMapping = _mappingByContentType.get(contentTypeId); 909 910 Map<String, String> fields = _getFields(objectDetails); 911 912 for (String attribute : contentTypeMapping.keySet()) 913 { 914 List<String> jsonKeys = contentTypeMapping.get(attribute); 915 List<Object> values = new ArrayList<>(); 916 917 for (String key : jsonKeys) 918 { 919 String value = fields.get(key); 920 921 if (value != null && !value.isEmpty() && !value.isBlank()) 922 { 923 values.add(value); 924 } 925 926 } 927 928 result.put(attribute, values); // Add the retrieved metadata values list to the contentResult 929 } 930 931 // Add the ID field 932 String syncCode = _createPegaseSyncCode(objectDetails); 933 if (syncCode != null) 934 { 935 result.put(getIdField(), List.of(syncCode)); 936 } 937 938 return result; 939 } 940 941 private Map<String, Collection<? extends Object>> _objectDetailsToMap(ObjetMaquette objectDetails, Collection<Enfant> children, Arbre tree, Logger logger) 942 { 943 Map<String, Collection<? extends Object>> result = new HashMap<>(); 944 945 // Compute category depending on child - to create a Container if a GROUPEMENT has no child of type OP 946 String category = _getObjectCategory(objectDetails, children, tree); 947 948 // Fill map with fields of the current object 949 result.putAll(_getObjectFields(objectDetails, category)); 950 951 // Compute children depending the current object 952 Collection<ObjetMaquette> childrenDetails = "GROUPEMENT".equals(category) 953 ? _filterGroupChildren(objectDetails, children, tree, logger) // Keep only OP under GROUPEMENT - ignore other objects 954 : _getObjectChildren(objectDetails, children, tree); // Insert a GROUPEMENT if there are OP 955 956 // Fill map with computed children 957 result.put("childrenToSend", childrenDetails); 958 result.put("children", childrenDetails.stream() 959 .map(ObjetMaquette::getCode) 960 .collect(Collectors.toList())); 961 962 return result; 963 } 964 965 private String _getObjectCategory(ObjetMaquette objectDetails, Collection<Enfant> children, Arbre tree) 966 { 967 String category = objectDetails.getCategorie(); 968 if ("GROUPEMENT".equals(category) && children.stream() 969 .map(child -> _getChildObjectDetails(child, tree)) 970 .map(ObjetMaquette::getCategorie) 971 .noneMatch("OP"::equals)) 972 { 973 // If there is no OP under the GROUPEMENT, we create a container (OTT) rather than a courseList (GROUPEMENT) 974 category = "OTT"; 975 } 976 977 return category; 978 } 979 980 private Collection<ObjetMaquette> _filterGroupChildren(ObjetMaquette group, Collection<Enfant> children, Arbre tree, Logger logger) 981 { 982 Collection<ObjetMaquette> filteredChildren = new ArrayList<>(); 983 984 for (Enfant child : children) 985 { 986 ObjetMaquette childDetails = _getChildObjectDetails(child, tree); 987 if ("OP".equals(childDetails.getCategorie())) 988 { 989 filteredChildren.add(childDetails); 990 } 991 else 992 { 993 // A GROUPEMENT can only have OP children to match courseList content type with courses children 994 ContentWorkflowDescription childWorkflowDescription = _mapWorkflowDescription(childDetails.getCategorie()); 995 String childType = childWorkflowDescription.getContentType(); 996 String courseListType = CourseListFactory.COURSE_LIST_CONTENT_TYPE; 997 logger.warn("The child content '{}' of type '{}' is not compatible with parent content '{}' of type '{}'.", childDetails.getCode(), childType, group.getCode(), courseListType); 998 } 999 } 1000 1001 return filteredChildren; 1002 } 1003 1004 private Collection<ObjetMaquette> _getObjectChildren(ObjetMaquette objetDetails, Collection<Enfant> children, Arbre tree) 1005 { 1006 Collection<ObjetMaquette> childrenDetails = new ArrayList<>(); 1007 Map<String, Enfant> courses = new LinkedHashMap<>(); 1008 for (Enfant child : children) 1009 { 1010 ObjetMaquette childDetails = _getChildObjectDetails(child, tree); 1011 if ("OP".equals(childDetails.getCategorie())) 1012 { 1013 // Put OP aside to add them as children of the inserted GROUPEMENT 1014 courses.put(childDetails.getCode(), child); 1015 } 1016 else 1017 { 1018 // Keep other children 1019 childrenDetails.add(childDetails); 1020 } 1021 } 1022 1023 if (!courses.isEmpty()) 1024 { 1025 // Create a GROUPEMENT Object 1026 ObjetMaquette childGroup = new ObjetMaquette(); 1027 1028 String code = _generateInsertedGroupementUniqueCode(objetDetails); 1029 childGroup.setCode(code); 1030 childGroup.setLibelle(code); 1031 childGroup.setLibelleLong(code); 1032 childGroup.setId(code); 1033 1034 ObjetLibelle type = new ObjetLibelle(); 1035 type.setCode("GROUPEMENT"); 1036 1037 childGroup.setType(type); 1038 childGroup.setCategorie("GROUPEMENT"); 1039 1040 // Add courses that has been put aside as children of the new GROUPEMENT 1041 childGroup.setEnfants(courses); 1042 1043 // Add the GROUPEMENT to the current object children 1044 childrenDetails.add(childGroup); 1045 } 1046 1047 return childrenDetails; 1048 } 1049 1050 private String _generateInsertedGroupementUniqueCode(ObjetMaquette parent) 1051 { 1052 int maxPrefixWidth = __PEGASE_SYNC_CODE_MAX_LENGTH - __INSERTED_GROUPEMENT_SUFFIX.length(); 1053 String codePrefix = parent.getCode(); 1054 1055 if (codePrefix.length() > maxPrefixWidth) 1056 { 1057 // Create a hashCode of the prefix. Hash code is an integer that will take less than 20 characters 1058 int parentHashedCode = codePrefix.hashCode(); 1059 codePrefix = Integer.toHexString(parentHashedCode).toUpperCase(); 1060 } 1061 1062 return codePrefix + __INSERTED_GROUPEMENT_SUFFIX; 1063 } 1064 1065 private ObjetMaquette _getChildObjectDetails(Enfant child, Arbre tree) 1066 { 1067 Ref enfantRef = child.getRef(); 1068 String code = enfantRef.getCode(); 1069 return tree.getObjetsMaquette().get(code); 1070 } 1071 1072 private Map<String, String> _getFields(ObjetMaquette objectDetails) 1073 { 1074 ObjetMaquetteDetails detail = objectDetails.getDetail(); 1075 Map<String, String> basicFields = Map.of( 1076 "id", StringUtils.defaultString(objectDetails.getId()), 1077 "code", StringUtils.defaultString(objectDetails.getCode()), 1078 "libelleCourt", StringUtils.defaultString(objectDetails.getLibelle()), 1079 "libelleLong", StringUtils.defaultString(objectDetails.getLibelleLong()), 1080 "description", StringUtils.defaultString(objectDetails.getDescription()), 1081 "ects", Optional.ofNullable(objectDetails.getEcts()) 1082 .map(value -> value.toString()) 1083 .orElse(StringUtils.EMPTY), 1084 "type", Optional.ofNullable(objectDetails.getType()) // Get the ObjetLibelle nature 1085 .map(objLibelleNature -> objLibelleNature.getCode()) // If the ObjetLibelle is not null, get the code 1086 .orElse(StringUtils.EMPTY) // Else empty string 1087 ); 1088 1089 Map<String, String> allFields = new HashMap<>(basicFields); 1090 1091 if (detail != null) 1092 { 1093 String choices = Optional.ofNullable(objectDetails.getPlageDeChoix()) 1094 .map(Object::toString) 1095 .orElse(StringUtils.EMPTY); 1096 1097 ObjetMaquetteGroupement group = detail.getGroupement(); 1098 if (group != null && choices.isEmpty()) 1099 { 1100 choices = Optional.ofNullable(group.getPlageDeChoix()) 1101 .map(Object::toString) 1102 .orElse(StringUtils.EMPTY); 1103 } 1104 1105 allFields.put("plageDeChoix", choices); 1106 1107 ObjetMaquetteFormation program = detail.getFormation(); 1108 if (program != null) 1109 { 1110 Map<String, String> programFields = Map.of( 1111 "codeTypeDiplome", StringUtils.defaultString(program.getTypeDiplome()), 1112 "codeNiveauFormation", StringUtils.defaultString(program.getNiveauFormation()), 1113 "codeDomaineFormation", StringUtils.defaultString(program.getDomaineFormation()), 1114 "codeNiveauDiplome", StringUtils.defaultString(program.getNiveauDiplome()), 1115 "codeMention", StringUtils.defaultString(program.getMention()), 1116 "codeChampFormation", StringUtils.defaultString(program.getChampFormation()), 1117 "version", Optional.ofNullable(program.getVersion()) 1118 .map(Object::toString) 1119 .orElse(StringUtils.EMPTY), 1120 "codeNatureDiplome", Optional.ofNullable(program.getTypeFormation()) 1121 .map(ObjetMaquetteFormationTypeFormation::getLibelle) 1122 .orElse(StringUtils.EMPTY), 1123 "codeNatureDiplomeCode", Optional.ofNullable(program.getTypeFormation()) 1124 .map(ObjetMaquetteFormationTypeFormation::getCode) 1125 .orElse(StringUtils.EMPTY) 1126 ); 1127 1128 allFields.putAll(programFields); 1129 } 1130 } 1131 1132 return allFields; 1133 } 1134}