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.util.ArrayList;
021import java.util.Collection;
022import java.util.HashMap;
023import java.util.HashSet;
024import java.util.LinkedHashMap;
025import java.util.LinkedHashSet;
026import java.util.List;
027import java.util.Map;
028import java.util.Optional;
029import java.util.Set;
030import java.util.UUID;
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.schedule.progression.ContainerProgressionTracker;
049import org.ametys.core.util.JSONUtils;
050import org.ametys.odf.ProgramItem;
051import org.ametys.odf.catalog.CatalogsManager;
052import org.ametys.odf.course.Course;
053import org.ametys.odf.course.CourseFactory;
054import org.ametys.odf.courselist.CourseList;
055import org.ametys.odf.courselist.CourseListFactory;
056import org.ametys.odf.program.ContainerFactory;
057import org.ametys.odf.program.ProgramFactory;
058import org.ametys.odf.program.ProgramPart;
059import org.ametys.odf.program.SubProgramFactory;
060import org.ametys.odf.program.TraversableProgramPart;
061import org.ametys.odf.workflow.AbstractCreateODFContentFunction;
062import org.ametys.plugins.contentio.synchronize.AbstractSimpleSynchronizableContentsCollection;
063import org.ametys.plugins.odfsync.pegase.ws.PegaseApiManager;
064import org.ametys.plugins.odfsync.utils.ContentWorkflowDescription;
065import org.ametys.plugins.repository.AmetysObjectIterable;
066import org.ametys.plugins.repository.jcr.NameHelper;
067import org.ametys.plugins.repository.query.expression.AndExpression;
068import org.ametys.plugins.repository.query.expression.Expression;
069import org.ametys.plugins.repository.query.expression.Expression.Operator;
070import org.ametys.plugins.repository.query.expression.StringExpression;
071import org.ametys.runtime.config.Config;
072import org.ametys.runtime.i18n.I18nizableText;
073import org.ametys.runtime.model.View;
074
075import com.opensymphony.workflow.WorkflowException;
076
077import fr.pcscol.pegase.odf.ApiException;
078import fr.pcscol.pegase.odf.api.MaquettesExterneApi;
079import fr.pcscol.pegase.odf.api.ObjetsMaquetteExterneApi;
080import fr.pcscol.pegase.odf.externe.model.DescripteursFormation;
081import fr.pcscol.pegase.odf.externe.model.DescripteursGroupement;
082import fr.pcscol.pegase.odf.externe.model.DescripteursObjetFormation;
083import fr.pcscol.pegase.odf.externe.model.DescripteursObjetMaquette;
084import fr.pcscol.pegase.odf.externe.model.DescripteursSise;
085import fr.pcscol.pegase.odf.externe.model.DescripteursSyllabus;
086import fr.pcscol.pegase.odf.externe.model.EnfantsStructure;
087import fr.pcscol.pegase.odf.externe.model.MaquetteStructure;
088import fr.pcscol.pegase.odf.externe.model.Nomenclature;
089import fr.pcscol.pegase.odf.externe.model.ObjetMaquetteDetail;
090import fr.pcscol.pegase.odf.externe.model.ObjetMaquetteStructure;
091import fr.pcscol.pegase.odf.externe.model.ObjetMaquetteSummary;
092import fr.pcscol.pegase.odf.externe.model.Pageable;
093import fr.pcscol.pegase.odf.externe.model.PagedObjetMaquetteSummaries;
094import fr.pcscol.pegase.odf.externe.model.PlageDeChoix;
095import fr.pcscol.pegase.odf.externe.model.TypeObjetMaquette;
096
097/**
098 * SCC for Pegase (COF).
099 */
100public class PegaseSynchronizableContentsCollection extends AbstractSimpleSynchronizableContentsCollection
101{
102    private static final String __INSERTED_GROUPEMENT_SUFFIX = "-LIST-";
103    
104    /** The JSON utils */
105    protected JSONUtils _jsonUtils;
106    
107    /** The catalogs manager */
108    protected CatalogsManager _catalogsManager;
109    
110    /** The Pégase API manager */
111    protected PegaseApiManager _pegaseApiManager;
112    
113    /** Mapping between metadata and columns */
114    protected Map<String, Map<String, String>> _mappingByContentType;
115    
116    /** Search fields to display */
117    protected Set<String> _searchFields;
118
119    /** List of imported contents */
120    protected Map<String, Integer> _importedContents;
121    
122    /** List of synchronized contents having differences */
123    protected Set<String> _synchronizedContents;
124    
125    /** List of updated contents by relation */
126    protected Set<String> _updatedRelationContents;
127    
128    /** Map to link contents to its children at the end of the process */
129    protected Map<String, Set<String>> _contentsChildren;
130    
131    /** Default language configured for ODF */
132    protected String _odfLang;
133    
134    /** The structure code for Pégase */
135    protected String _structureCode;
136    
137    @Override
138    public void service(ServiceManager manager) throws ServiceException
139    {
140        super.service(manager);
141        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
142        _catalogsManager = (CatalogsManager) manager.lookup(CatalogsManager.ROLE);
143        _pegaseApiManager = (PegaseApiManager) manager.lookup(PegaseApiManager.ROLE);
144    }
145    
146    @Override
147    public String getIdField()
148    {
149        return "pegaseSyncCode";
150    }
151
152    /**
153     * Get the identifier JSON field.
154     * @return the column id
155     */
156    protected String getIdColumn()
157    {
158        return "id";
159    }
160
161    @SuppressWarnings("unchecked")
162    @Override
163    public Set<String> getLocalAndExternalFields(Map<String, Object> additionalParameters)
164    {
165        return Optional.ofNullable(additionalParameters)
166            .map(params -> params.get("contentTypes"))
167            .filter(List.class::isInstance)
168            .map(cTypes -> (List<String>) cTypes)
169            .filter(Predicate.not(List::isEmpty))
170            .map(l -> l.get(0))
171            .map(_mappingByContentType::get)
172            .map(Map::keySet)
173            .orElse(Set.of());
174    }
175
176    @Override
177    protected Map<String, Object> putIdParameter(String idValue)
178    {
179        Map<String, Object> parameters = new HashMap<>();
180        parameters.put(getIdColumn(), List.of(idValue));
181        return parameters;
182    }
183    
184    @Override
185    protected void configureDataSource(Configuration configuration) throws ConfigurationException
186    {
187        _odfLang = Config.getInstance().getValue("odf.programs.lang");
188        _searchFields = new HashSet<>();
189        _mappingByContentType = new HashMap<>();
190        
191        if (Config.getInstance().getValue("pegase.activate", true, false))
192        {
193            _structureCode = Config.getInstance().getValue("pegase.structure.code");
194    
195            // Champs affichés dans la recherche
196            _searchFields.add("libelle");
197            _searchFields.add("code");
198            _searchFields.add("espace");
199            _searchFields.add("id");
200            
201            // Program
202            Map<String, String> contentTypeMapping = new HashMap<>();
203            contentTypeMapping.put("title", "libelle");
204            contentTypeMapping.put("pegaseCode", "code");
205            contentTypeMapping.put("ects", "ects");
206            contentTypeMapping.put("educationKind", "codeNatureDiplomeCode");
207            contentTypeMapping.put("presentation", "description");
208            contentTypeMapping.put("objectives", "objectif");
209            contentTypeMapping.put("neededPrerequisite", "prerequis");
210            contentTypeMapping.put("mention", "codeMention");
211            contentTypeMapping.put("domain", "codeDomaineFormation");
212            contentTypeMapping.put("programField", "codeChampFormation");
213            contentTypeMapping.put("educationLevel", "codeNiveauFormation");
214            contentTypeMapping.put("rncpLevel", "codeNiveauDiplome");
215            contentTypeMapping.put("degree", "codeTypeDiplome");
216            contentTypeMapping.put("orgUnit", "orgUnit");
217            _mappingByContentType.put(ProgramFactory.PROGRAM_CONTENT_TYPE, contentTypeMapping);
218            
219            // SubProgram
220            contentTypeMapping = new HashMap<>();
221            contentTypeMapping.put("title", "libelle");
222            contentTypeMapping.put("pegaseCode", "code");
223            contentTypeMapping.put("ects", "ects");
224            contentTypeMapping.put("presentation", "description");
225            contentTypeMapping.put("objectives", "objectif");
226            contentTypeMapping.put("neededPrerequisite", "prerequis");
227            contentTypeMapping.put("orgUnit", "orgUnit");
228            _mappingByContentType.put(SubProgramFactory.SUBPROGRAM_CONTENT_TYPE, contentTypeMapping);
229    
230            // Container
231            contentTypeMapping = new HashMap<>();
232            contentTypeMapping.put("title", "libelle");
233            contentTypeMapping.put("pegaseCode", "code");
234            contentTypeMapping.put("ects", "ects");
235            contentTypeMapping.put("nature", "typeObjetFormation");
236            contentTypeMapping.put("period", "periode");
237            _mappingByContentType.put(ContainerFactory.CONTAINER_CONTENT_TYPE, contentTypeMapping);
238            
239            // CourseList
240            contentTypeMapping = new HashMap<>();
241            contentTypeMapping.put("title", "libelle");
242            contentTypeMapping.put("pegaseCode", "code");
243            contentTypeMapping.put("plageDeChoix", "plageDeChoix");
244            contentTypeMapping.put("obligatoire", "obligatoire");
245            contentTypeMapping.put("min", "plageMin");
246            contentTypeMapping.put("max", "plageMax");
247            _mappingByContentType.put(CourseListFactory.COURSE_LIST_CONTENT_TYPE, contentTypeMapping);
248    
249            // Course
250            contentTypeMapping = new HashMap<>();
251            contentTypeMapping.put("title", "libelle");
252            contentTypeMapping.put("pegaseCode", "code");
253            contentTypeMapping.put("ects", "ects");
254            contentTypeMapping.put("description", "description");
255            contentTypeMapping.put("courseType", "typeObjetFormation");
256            _mappingByContentType.put(CourseFactory.COURSE_CONTENT_TYPE, contentTypeMapping);
257        }
258    }
259
260    @Override
261    protected void configureSearchModel()
262    {
263        List<String> sortableColumns = List.of("code", "libelle");
264
265        _searchModelConfiguration.addCriterion("libelle", new I18nizableText("plugin.odf-sync", "PLUGINS_ODF_SYNC_PEGASE_CRITERION_LIBELLE"), "string");
266        _searchModelConfiguration.addCriterion("validee", new I18nizableText("plugin.odf-sync", "PLUGINS_ODF_SYNC_PEGASE_CRITERION_VALIDEE"), "boolean", "edition.boolean-combobox");
267
268        for (String keptField : _searchFields)
269        {
270            if (sortableColumns.contains(keptField))
271            {
272                _searchModelConfiguration.addColumn(keptField, new I18nizableText(keptField), true);
273            }
274            else
275            {
276                _searchModelConfiguration.addColumn(keptField, new I18nizableText(keptField), false);
277            }
278        }
279    }
280
281    /**
282     * Get the catalog for import.
283     * @return the catalog
284     */
285    protected String getCatalog()
286    {
287        return Optional.of(getParameterValues())
288                   .map(params -> params.get("catalog"))
289                   .map(String.class::cast)
290                   .filter(StringUtils::isNotBlank)
291                   .orElseGet(() -> _catalogsManager.getDefaultCatalogName());
292    }
293    
294    @Override
295    protected Map<String, Map<String, Object>> internalSearch(Map<String, Object> parameters, int offset, int limit, List<Object> sort, Logger logger)
296    {
297        Map<String, Map<String, Object>> results = new LinkedHashMap<>();
298        
299        try
300        {
301            ObjetsMaquetteExterneApi objetsMaquetteApi = _pegaseApiManager.getObjetsMaquetteExterneApi();
302            
303            // Get details for all requested programs by objetsMaquetteApi
304            PagedObjetMaquetteSummaries pagedPrograms = _getPagedProgramsSummaries(parameters, offset, limit, sort, objetsMaquetteApi);
305            Long total = pagedPrograms.getTotalElements();
306            
307            if (total != null)
308            {
309                // if total is provided, store it so that getTotalCount won't do a useless full search
310                parameters.put("totalCount", total.longValue());
311            }
312            
313            List<ObjetMaquetteSummary> programs = pagedPrograms.getItems();
314            if (programs != null)
315            {
316                for (ObjetMaquetteSummary programSummary : programs)
317                {
318                    String pegaseSyncCode = programSummary.getId().toString();
319                    
320                    // If not an import, fill the results with Pégase synchronization code
321                    Map<String, Object> result = results.computeIfAbsent(pegaseSyncCode, __ -> new HashMap<>());
322                    Map<String, String> fields = _getSummaryFields(programSummary);
323
324                    for (String keptField : _searchFields)
325                    {
326                        String field = fields.get(keptField);
327                        result.put(keptField, field);
328                    }
329
330                    result.put(SCC_UNIQUE_ID, pegaseSyncCode);
331                }
332            }
333        }
334        catch (ApiException | IOException e)
335        {
336            throw new RuntimeException("Error while getting remote values", e);
337        }
338        
339        return results;
340    }
341    
342    @Override
343    public int getTotalCount(Map<String, Object> searchParameters, Logger logger)
344    {
345        // avoid to relaunch a full search if already done
346        Long totalCount = (Long) searchParameters.get("totalCount");
347        
348        if (totalCount != null)
349        {
350            return totalCount.intValue();
351        }
352        
353        return super.getTotalCount(searchParameters, logger);
354    }
355    
356    private PagedObjetMaquetteSummaries _getPagedProgramsSummaries(Map<String, Object> parameters, int offset, int limit, List<Object> sort, ObjetsMaquetteExterneApi objetsMaquetteApi) throws ApiException
357    {
358        Pageable pageable = new Pageable();
359        int pageNumber = offset / 50;
360        pageable.setPage(pageNumber);
361        pageable.setTaille(limit);
362        
363        if (sort == null)
364        {
365            pageable.setTri(List.of());
366        }
367        else
368        {
369            // Convert the sort parameter from Object to JSON
370            String jsonSortParameters = _jsonUtils.convertObjectToJson(sort.get(0));
371            // Convert the sort parameter from JSON to Map<String, Object>
372            Map<String, Object> sortParameters = _jsonUtils.convertJsonToMap(jsonSortParameters);
373            // Create the list containing the sort result; it is going to be of the form : ["field,direction"]
374            List<String> sortParametersArray = new ArrayList<>();
375            // Create the sort parameter that is going to be sent in the list; it is going to be of the form : "field,direction"
376            StringBuilder stringBuilder = new StringBuilder();
377            // Get the parameter "property" which is the column on which the sorting is meant to be made
378            String property = (String) sortParameters.get("property");
379            
380            if (!"code".equals(property) && !"libelle".equals(property))
381            {
382                if ("libelle".equals(property))
383                {
384                    stringBuilder.append("libelle,").append((String) sortParameters.get("direction"));
385                    sortParametersArray.add(stringBuilder.toString());
386                }
387            }
388            else
389            {
390                stringBuilder.append(property).append(",").append((String) sortParameters.get("direction"));
391                sortParametersArray.add(stringBuilder.toString());
392            }
393            
394            pageable.setTri(sortParametersArray);
395        }
396        
397        String searchLabel = (String) parameters.get("libelle");
398        List<TypeObjetMaquette> typeObjets = List.of(TypeObjetMaquette.FORMATION);
399        Boolean validated = (Boolean) parameters.get("validee");
400        return objetsMaquetteApi.rechercherObjetMaquette(_structureCode, pageable, searchLabel, null, typeObjets, null, null, null, null, null, validated, null);
401    }
402
403    private Map<String, String> _getSummaryFields(ObjetMaquetteSummary objectSummary)
404    {
405        return Map.of("id", objectSummary.getId().toString(),
406                      "code",  StringUtils.defaultString(objectSummary.getCode()),
407                      "libelle", StringUtils.defaultString(objectSummary.getLibelle()),
408                      "espace", StringUtils.defaultString(objectSummary.getEspaceLibelle()));
409    }
410    
411    @SuppressWarnings("unchecked")
412    @Override
413    protected Map<String, Map<String, List<Object>>> getRemoteValues(Map<String, Object> parameters, Logger logger)
414    {
415        Map<String, Map<String, List<Object>>> results = new LinkedHashMap<>();
416
417        List<String> pegaseSyncCodeValues = (List<String>) parameters.getOrDefault(getIdColumn(), new ArrayList<>());
418        
419        List<String> idValues = new ArrayList<>();
420        
421        try
422        {
423            ObjetsMaquetteExterneApi objetsMaquetteApi = _pegaseApiManager.getObjetsMaquetteExterneApi();
424            MaquettesExterneApi maquettesApi = _pegaseApiManager.getMaquettesExterneApi();
425
426            if (!pegaseSyncCodeValues.isEmpty())
427            {
428                // import or synchronize single items
429                idValues.addAll(pegaseSyncCodeValues);
430            }
431            else
432            {
433                // global synchronization
434                PagedObjetMaquetteSummaries pagedPrograms = _getPagedProgramsSummaries(parameters, 0, Integer.MAX_VALUE, null, objetsMaquetteApi);
435                for (ObjetMaquetteSummary programDetails : pagedPrograms.getItems())
436                {
437                    idValues.add(programDetails.getId().toString());
438                }
439            }
440            
441            results.putAll(_getObjectDetailsForImport(idValues, objetsMaquetteApi, maquettesApi, logger));
442        }
443        catch (ApiException | IOException e)
444        {
445            throw new RuntimeException("Error while getting remote values", e);
446        }
447        
448        return results;
449    }
450
451    Map<String, Map<String, List<Object>>> _getObjectDetailsForImport(List<String> idValues, ObjetsMaquetteExterneApi objetsMaquetteApi, MaquettesExterneApi maquettesApi, Logger logger) throws ApiException
452    {
453        Map<String, Map<String, List<Object>>> results = new LinkedHashMap<>();
454        Set<UUID> alreadyHandledObjects = new HashSet<>();
455
456        for (String idValue : idValues)
457        {
458            // Get the program tree structure from the idValue
459            MaquetteStructure maquette = maquettesApi.lireStructureMaquette(_structureCode, UUID.fromString(idValue));
460            ObjetMaquetteStructure racine = maquette.getRacine();
461            
462            results.putAll(_getObjectDetailsForImport(racine, null, alreadyHandledObjects, objetsMaquetteApi, logger));
463        }
464        
465        return results;
466    }
467    
468    private Map<String, Map<String, List<Object>>> _getObjectDetailsForImport(ObjetMaquetteStructure item, EnfantsStructure structure, Set<UUID> alreadyHandledObjects, ObjetsMaquetteExterneApi objetsMaquetteApi, Logger logger) throws ApiException
469    {
470        Map<String, Map<String, List<Object>>> results = new LinkedHashMap<>();
471        
472        UUID id = item.getId();
473        
474        if (alreadyHandledObjects.add(id))
475        {
476            ObjetMaquetteDetail detail = objetsMaquetteApi.lireObjetMaquette(_structureCode, id);
477            
478            // get data for this particular Pegase object
479            String type = item.getType();
480            if (item.getClasse().equals("F"))
481            {
482                type = "FORMATION";
483            }
484            else if (item.getClasse().equals("G"))
485            {
486                type = "GROUPEMENT";
487            }
488
489            Map<String, List<Object>> objectData = _getObjectFields(detail, structure, type);
490            
491            // then compute Ametys children from the Pegase structure
492            ComputedChildren children = _computeChildren(item);
493            
494            // link to Ametys children
495            objectData.put("children", children.childrenIds());
496            
497            results.put(id.toString(), objectData);
498            
499            // add the newly created intermediary objects, if any
500            results.putAll(children.newObjects());
501            
502            // then finally import recursively next objects
503            for (EnfantsStructure child : children.nextObjects())
504            {
505                results.putAll(_getObjectDetailsForImport(child.getObjetMaquette(), child, alreadyHandledObjects, objetsMaquetteApi, logger));
506            }
507        }
508        
509        return results;
510    }
511    
512    private ComputedChildren _computeChildren(ObjetMaquetteStructure item)
513    {
514        List<EnfantsStructure> enfants = item.getEnfants();
515        
516        List<Object> childrenIds = new ArrayList<>();
517        List<EnfantsStructure> nextObjects = new ArrayList<>();
518        Map<String, Map<String, List<Object>>> newObjects = new HashMap<>();
519        
520        if (_isProgramPart(item))
521        {
522            // item is a programpart, we allow other programparts as direct children
523            List<EnfantsStructure> programPartChildren = enfants.stream().filter(e -> _isProgramPart(e.getObjetMaquette())).toList();
524            childrenIds.addAll(programPartChildren.stream().map(e -> e.getObjetMaquette().getId().toString()).toList());
525            nextObjects.addAll(programPartChildren);
526            
527            // if there are direct courses, we should inject an intermediary list
528            _computeIntermediateLists(item, enfants, childrenIds, nextObjects, newObjects);
529            
530            // add groups
531            List<EnfantsStructure> listChildren = enfants.stream().filter(e -> _isCourseList(e.getObjetMaquette())).toList();
532            childrenIds.addAll(listChildren.stream().map(e -> e.getObjetMaquette().getId().toString()).toList());
533            nextObjects.addAll(listChildren);
534        }
535        else if (_isCourseList(item))
536        {
537            List<EnfantsStructure> courseChildren = enfants.stream().filter(e -> _isCourse(e.getObjetMaquette())).toList();
538            childrenIds.addAll(courseChildren.stream().map(e -> e.getObjetMaquette().getId().toString()).toList());
539            nextObjects.addAll(courseChildren);
540        }
541        else if (_isCourse(item))
542        {
543            // if there are direct courses, we should inject an intermediary list
544            _computeIntermediateLists(item, enfants, childrenIds, nextObjects, newObjects);
545            
546            // add groups
547            List<EnfantsStructure> listChildren = enfants.stream().filter(e -> _isCourseList(e.getObjetMaquette())).toList();
548            childrenIds.addAll(listChildren.stream().map(e -> e.getObjetMaquette().getId().toString()).toList());
549            nextObjects.addAll(listChildren);
550        }
551        
552        return new ComputedChildren(childrenIds, newObjects, nextObjects);
553    }
554    
555    private boolean _isProgramPart(ObjetMaquetteStructure item)
556    {
557        String classe = item.getClasse();
558        String type = item.getType();
559        
560        // the Pegase item reprensents either a program or a subprogram, year or semester
561        return "F".equals(classe) || "O".equals(classe) && List.of("PARCOURS-TYPE", "ANNEE", "SEMESTRE").contains(type);
562    }
563    
564    private boolean _isCourse(ObjetMaquetteStructure item)
565    {
566        String classe = item.getClasse();
567        String type = item.getType();
568        
569        return "O".equals(classe) && List.of("UE", "EC", "ENS", "MATIERE").contains(type);
570    }
571    
572    private boolean _isCourseList(ObjetMaquetteStructure item)
573    {
574        String classe = item.getClasse();
575        return "G".equals(classe);
576    }
577    
578    private void _computeIntermediateLists(ObjetMaquetteStructure item, List<EnfantsStructure> enfants, List<Object> childrenIds, List<EnfantsStructure> nextObjects, Map<String, Map<String, List<Object>>> newObjects)
579    {
580        List<EnfantsStructure> courseChildren = enfants.stream().filter(e -> _isCourse(e.getObjetMaquette()) && e.getObligatoire()).toList();
581        _computeIntermediateList(item, courseChildren, true, childrenIds, nextObjects, newObjects);
582        
583        courseChildren = enfants.stream().filter(e -> _isCourse(e.getObjetMaquette()) && !e.getObligatoire()).toList();
584        _computeIntermediateList(item, courseChildren, false, childrenIds, nextObjects, newObjects);
585    }
586    
587    private void _computeIntermediateList(ObjetMaquetteStructure item, List<EnfantsStructure> courseChildren, boolean mandatory, List<Object> childrenIds, List<EnfantsStructure> nextObjects, Map<String, Map<String, List<Object>>> newObjects)
588    {
589        if (!courseChildren.isEmpty())
590        {
591            String listId = item.getId() + __INSERTED_GROUPEMENT_SUFFIX + (mandatory ? "O" : "F");
592            childrenIds.add(listId);
593            
594            Map<String, List<Object>> listData = new HashMap<>();
595            listData.put("workflowDescription", List.of(ContentWorkflowDescription.COURSELIST_WF_DESCRIPTION));
596            listData.put(getIdField(), List.of(listId));
597            listData.put("title", List.of(item.getLibelle() + " - Liste " + (mandatory ? "O" : "F")));
598            listData.put("obligatoire", List.of(String.valueOf(mandatory)));
599            listData.put("plageDeChoix", List.of("false"));
600            listData.put("children", courseChildren.stream().map(e -> e.getObjetMaquette().getId().toString()).collect(Collectors.toList()));
601            
602            newObjects.put(listId, listData);
603            nextObjects.addAll(courseChildren);
604        }
605    }
606    
607    private Map<String, List<Object>> _getObjectFields(ObjetMaquetteDetail objectDetails, EnfantsStructure structure, String categorie)
608    {
609        Map<String, List<Object>> result = new HashMap<>();
610
611        ContentWorkflowDescription wfDescription = _mapWorkflowDescription(categorie);
612        result.put("workflowDescription", List.of(wfDescription));
613        
614        String contentTypeId = wfDescription.getContentType();
615        Map<String, String> contentTypeMapping = _mappingByContentType.get(contentTypeId);
616        
617        Map<String, Object> fields = _getFields(objectDetails, structure);
618        
619        for (String attribute : contentTypeMapping.keySet())
620        {
621            String jsonKey = contentTypeMapping.get(attribute);
622            Object value = fields.get(jsonKey);
623            
624            result.put(attribute, value != null ? List.of(value) : null); // Add the retrieved metadata values list to the contentResult
625        }
626        
627        // Add the ID field
628        result.put(getIdField(), List.of(objectDetails.getId().toString()));
629        
630        return result;
631    }
632    
633    /**
634     * Get the workflow description for the given object type.
635     * @param type The type
636     * @return the matching workflow description
637     * @throws IllegalArgumentException if the given categorie does not match any content workflow
638     */
639    protected ContentWorkflowDescription _mapWorkflowDescription(String type) throws IllegalArgumentException
640    {
641        switch (type)
642        {
643            case "FORMATION":
644                return ContentWorkflowDescription.PROGRAM_WF_DESCRIPTION;
645            case "PARCOURS-TYPE":
646                return ContentWorkflowDescription.SUBPROGRAM_WF_DESCRIPTION;
647            case "ANNEE":
648            case "SEMESTRE":
649                return ContentWorkflowDescription.CONTAINER_WF_DESCRIPTION;
650            case "GROUPEMENT":
651                return ContentWorkflowDescription.COURSELIST_WF_DESCRIPTION;
652            case "UE":
653            case "EC":
654            case "ENS":
655            case "MATIERE":
656                return ContentWorkflowDescription.COURSE_WF_DESCRIPTION;
657            default:
658                throw new IllegalArgumentException("The type '" + type + "' cannot be converted to an Ametys content type.");
659        }
660    }
661
662    private Map<String, Object> _getFields(ObjetMaquetteDetail objectDetails, EnfantsStructure structure)
663    {
664        Map<String, Object> fields = new HashMap<>();
665                
666        fields.put("id", objectDetails.getId().toString());
667        fields.put("code",  StringUtils.trimToNull(objectDetails.getCode()));
668        
669        DescripteursObjetMaquette descripteursObjetMaquette = objectDetails.getDescripteursObjetMaquette();
670        String libelle = StringUtils.trimToNull(descripteursObjetMaquette.getLibelle());
671        fields.put("libelle", libelle);
672        fields.put("libelleLong", StringUtils.trimToNull(descripteursObjetMaquette.getLibelleLong()));
673        
674        if (descripteursObjetMaquette instanceof DescripteursFormation descripteursFormation)
675        {
676            fields.put("ects", descripteursFormation.getEcts());
677            fields.put("orgUnit", StringUtils.trimToNull(descripteursFormation.getStructurePrincipale()));
678        }
679        else if (descripteursObjetMaquette instanceof DescripteursGroupement descripteursGroupement)
680        {
681            fields.put("obligatoire", String.valueOf(structure.getObligatoire()));
682            
683            PlageDeChoix plageDeChoix = descripteursGroupement.getPlageDeChoix();
684            if (plageDeChoix != null)
685            {
686                fields.put("plageDeChoix", "true");
687                fields.put("plageMin", descripteursGroupement.getPlageDeChoix().getMin());
688                fields.put("plageMax", descripteursGroupement.getPlageDeChoix().getMax());
689            }
690        }
691        else if (descripteursObjetMaquette instanceof DescripteursObjetFormation descripteursObjetFormation)
692        {
693            fields.put("ects", descripteursObjetFormation.getEcts());
694            fields.put("orgUnit", StringUtils.trimToNull(descripteursObjetFormation.getStructurePrincipale()));
695            
696            String type = _getNomenclature(descripteursObjetFormation.getType());
697            fields.put("typeObjetFormation", type);
698            
699            if ("ANNEE".equals(type))
700            {
701                fields.put("periode", "an");
702            }
703            else if ("SEMESTRE".equals(type))
704            {
705                _computeSemesterPeriod(libelle, fields);
706            }
707        }
708        
709        DescripteursSyllabus syllabus = objectDetails.getDescripteursSyllabus();
710        if (syllabus != null)
711        {
712            fields.put("description", StringUtils.trimToNull(syllabus.getDescription()));
713            fields.put("langueEnseignement", StringUtils.trimToNull(syllabus.getLangueEnseignement()));
714            fields.put("objectif", StringUtils.trimToNull(syllabus.getObjectif()));
715            fields.put("prerequis", StringUtils.trimToNull(syllabus.getPrerequisPedagogique()));
716        }
717        
718        DescripteursSise sise = objectDetails.getDescripteursEnquete().getDescripteursSise();
719        if (sise != null)
720        {
721            fields.put("codeTypeDiplome", _getNomenclature(sise.getTypeDiplome()));
722            fields.put("codeMention", _getNomenclature(sise.getMention()));
723            fields.put("codeNiveauDiplome", _getNomenclature(sise.getNiveauDiplome()));
724            fields.put("codeChampFormation", _getNomenclature(sise.getChampFormation()));
725            fields.put("codeDomaineFormation", _getNomenclature(sise.getDomaineFormation()));
726        }
727        
728        return fields;
729    }
730    
731    private String _getNomenclature(Nomenclature nomenclature)
732    {
733        return nomenclature != null ? StringUtils.trimToNull(nomenclature.getCode()) : null;
734    }
735    
736    private void _computeSemesterPeriod(String libelle, Map<String, Object> fields)
737    {
738        if (StringUtils.containsIgnoreCase(libelle, "Semestre 1") || StringUtils.containsIgnoreCase(libelle, "S1"))
739        {
740            fields.put("periode", "s1");
741        }
742        else if (StringUtils.containsIgnoreCase(libelle, "Semestre 2") || StringUtils.containsIgnoreCase(libelle, "S2"))
743        {
744            fields.put("periode", "s2");
745        }
746        else if (StringUtils.containsIgnoreCase(libelle, "Semestre 3") || StringUtils.containsIgnoreCase(libelle, "S3"))
747        {
748            fields.put("periode", "s3");
749        }
750        else if (StringUtils.containsIgnoreCase(libelle, "Semestre 4") || StringUtils.containsIgnoreCase(libelle, "S4"))
751        {
752            fields.put("periode", "s4");
753        }
754        else if (StringUtils.containsIgnoreCase(libelle, "Semestre 5") || StringUtils.containsIgnoreCase(libelle, "S5"))
755        {
756            fields.put("periode", "s5");
757        }
758        else if (StringUtils.containsIgnoreCase(libelle, "Semestre 6") || StringUtils.containsIgnoreCase(libelle, "S6"))
759        {
760            fields.put("periode", "s6");
761        }
762    }
763
764    @Override
765    public List<ModifiableContent> importContent(String idValue, Map<String, Object> importParams, Logger logger) throws Exception
766    {
767        Map<String, Object> parameters = putIdParameter(idValue);
768        return _importOrSynchronizeContents(parameters, true, logger);
769    }
770    
771    @Override
772    public void synchronizeContent(ModifiableContent content, Logger logger) throws Exception
773    {
774        String idValue = content.getValue(getIdField());
775        Map<String, Object> parameters = putIdParameter(idValue);
776        _importOrSynchronizeContents(parameters, true, logger);
777    }
778    
779    @Override
780    protected List<ModifiableContent> _importOrSynchronizeContents(Map<String, Object> searchParams, boolean forceImport, Logger logger, ContainerProgressionTracker progressionTracker)
781    {
782        _importedContents = new HashMap<>();
783        _synchronizedContents = new HashSet<>();
784        _updatedRelationContents = new HashSet<>();
785        _contentsChildren = new HashMap<>();
786        
787        List<ModifiableContent> contents =  super._importOrSynchronizeContents(searchParams, forceImport, logger, progressionTracker);
788        _updateRelations(logger);
789        _updateWorkflowStatus(logger);
790        
791        _importedContents = null;
792        _synchronizedContents = null;
793        _updatedRelationContents = null;
794        _contentsChildren = null;
795        
796        return contents;
797    }
798    
799    /**
800     * Get or create the content defined by the given parameters.
801     * @param lang The content language
802     * @param idValue The synchronization code
803     * @param remoteValues The remote values
804     * @param forceImport <code>true</code> to force import (only on single import or unlimited global synchronization)
805     * @param logger The logger
806     * @return the content
807     * @throws Exception if an error occurs
808     */
809    protected ModifiableContent _getOrCreateContent(String lang, String idValue, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger) throws Exception
810    {
811        ContentWorkflowDescription wfDescription = (ContentWorkflowDescription) remoteValues.get("workflowDescription").get(0);
812        ModifiableContent content = _getContent(lang, idValue, wfDescription.getContentType());
813        if (content == null && (forceImport || !synchronizeExistingContentsOnly()))
814        {
815            // Calculate contentTitle
816            String contentTitle = Optional.of(remoteValues)
817                .map(v -> v.get("title"))
818                .map(List::stream)
819                .orElseGet(Stream::empty)
820                .filter(String.class::isInstance)
821                .map(String.class::cast)
822                .filter(StringUtils::isNotEmpty)
823                .findFirst()
824                .orElse(idValue);
825            
826            String contentName = NameHelper.filterName(_contentPrefix + "-" + contentTitle + "-" + lang);
827            
828            Map<String, Object> inputs = new HashMap<>();
829            String catalog = getCatalog();
830            if (catalog != null)
831            {
832                inputs.put(AbstractCreateODFContentFunction.CONTENT_CATALOG_KEY, catalog);
833            }
834            
835            Map<String, Object> resultMap = _contentWorkflowHelper.createContent(
836                    wfDescription.getWorkflowName(),
837                    wfDescription.getInitialActionId(),
838                    contentName,
839                    contentTitle,
840                    new String[] {wfDescription.getContentType()},
841                    null,
842                    lang,
843                    inputs);
844            
845            if ((boolean) resultMap.getOrDefault("error", false))
846            {
847                _nbError++;
848            }
849            
850            content = (ModifiableContent) resultMap.get(Content.class.getName());
851
852            if (content != null)
853            {
854                _sccHelper.updateSCCProperty(content, getId());
855                
856                // Set sync code
857                content.setValue(getIdField(), idValue);
858                
859                content.saveChanges();
860                _importedContents.put(content.getId(), wfDescription.getValidationActionId());
861                _nbCreatedContents++;
862            }
863        }
864        return content;
865    }
866    
867    /**
868     * Get the content from the synchronization code, the lang, the catalog and the content type.
869     * @param lang The lang
870     * @param syncCode The synchronization code
871     * @param contentType The content type
872     * @return the retrieved content
873     */
874    protected ModifiableContent _getContent(String lang, String syncCode, String contentType)
875    {
876        List<Expression> expList = _getExpressionsList(lang, syncCode, contentType);
877        AndExpression andExp = new AndExpression(expList.toArray(new Expression[expList.size()]));
878        String xPathQuery = ContentQueryHelper.getContentXPathQuery(andExp);
879
880        AmetysObjectIterable<ModifiableContent> contents = _resolver.query(xPathQuery);
881
882        if (contents.getSize() > 0)
883        {
884            return contents.iterator().next();
885        }
886        
887        return null;
888    }
889    
890    @Override
891    protected Optional<ModifiableContent> _importOrSynchronizeContent(String idValue, String lang, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger)
892    {
893        try
894        {
895            ModifiableContent content = _getOrCreateContent(lang, idValue, remoteValues, forceImport, logger);
896            if (content != null)
897            {
898                return Optional.of(_synchronizeContent(content, remoteValues, logger));
899            }
900        }
901        catch (Exception e)
902        {
903            _nbError++;
904            logger.error("An error occurred while importing or synchronizing content", e);
905        }
906        
907        return Optional.empty();
908    }
909    
910    @SuppressWarnings("unchecked")
911    @Override
912    protected ModifiableContent _synchronizeContent(ModifiableContent content, Map<String, List<Object>> remoteValues, Logger logger) throws Exception
913    {
914        super._synchronizeContent(content, remoteValues, logger);
915        // Add children to the list to handle later to add relations
916        if (remoteValues.containsKey("children"))
917        {
918            Set<String> children = _contentsChildren.computeIfAbsent(content.getId(), __ -> new LinkedHashSet<>());
919            children.addAll((List<String>) (Object) remoteValues.get("children"));
920        }
921        return content;
922    }
923    
924    @Override
925    protected boolean _fillContent(Map<String, List<Object>> remoteValues, ModifiableContent content, Map<String, Object> additionalParameters, boolean create, Logger logger) throws Exception
926    {
927        _synchronizedContents.add(content.getId());
928        return super._fillContent(remoteValues, content, additionalParameters, create, logger);
929    }
930    
931    @Override
932    public List<String> getLanguages()
933    {
934        return List.of(_odfLang);
935    }
936
937    @Override
938    protected List<Expression> _getExpressionsList(String lang, String idValue, String contentType)
939    {
940        List<Expression> expList = super._getExpressionsList(lang, idValue, contentType);
941        String catalog = getCatalog();
942        if (catalog != null)
943        {
944            expList.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
945        }
946        return expList;
947    }
948    
949    @Override
950    protected Map<String, Object> _transformRemoteValuesCardinality(Map<String, List<Object>> remoteValues, String obsoleteContentTypeId)
951    {
952        String realContentTypeId = Optional.of(remoteValues)
953                .map(v -> v.get("workflowDescription"))
954                .map(l -> l.get(0))
955                .map(ContentWorkflowDescription.class::cast)
956                .map(ContentWorkflowDescription::getContentType)
957                .orElse(null);
958        return super._transformRemoteValuesCardinality(remoteValues, realContentTypeId);
959    }
960    
961    private void _updateRelations(Logger logger)
962    {
963        for (String contentId : _contentsChildren.keySet())
964        {
965            WorkflowAwareContent content = _resolver.resolveById(contentId);
966            Set<String> childrenCodes = _contentsChildren.get(contentId);
967            String contentLanguage = content.getLanguage();
968            String contentCatalog = content.getValue("catalog");
969            Map<String, Set<String>> childrenByAttributeName = new HashMap<>();
970            
971            for (String childCode : childrenCodes)
972            {
973                Expression expression = new AndExpression(
974                    _sccHelper.getCollectionExpression(getId()),
975                    new StringExpression(getIdField(), Operator.EQ, childCode),
976                    new StringExpression("catalog", Operator.EQ, contentCatalog),
977                    new LanguageExpression(Operator.EQ, contentLanguage)
978                );
979                
980                ModifiableContent childContent = _resolver.<ModifiableContent>query(ContentQueryHelper.getContentXPathQuery(expression))
981                    .stream()
982                    .findFirst()
983                    .orElse(null);
984                
985                if (childContent == null)
986                {
987                    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);
988                }
989                else
990                {
991                    String attributesName = _getChildAttributeName(content, childContent);
992                    if (attributesName != null)
993                    {
994                        Set<String> children = childrenByAttributeName.computeIfAbsent(attributesName, __ -> new LinkedHashSet<>());
995                        children.add(childContent.getId());
996                    }
997                    else
998                    {
999                        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]);
1000                    }
1001                }
1002            }
1003            _updateRelations(content, childrenByAttributeName, logger);
1004        }
1005    }
1006    
1007    private String _getChildAttributeName(Content parentContent, Content childContent)
1008    {
1009        if (childContent instanceof Course && parentContent instanceof CourseList)
1010        {
1011            return CourseList.CHILD_COURSES;
1012        }
1013        
1014        if (parentContent instanceof Course && childContent instanceof CourseList)
1015        {
1016            return Course.CHILD_COURSE_LISTS;
1017        }
1018        
1019        if (parentContent instanceof TraversableProgramPart && childContent instanceof ProgramPart)
1020        {
1021            return TraversableProgramPart.CHILD_PROGRAM_PARTS;
1022        }
1023        
1024        return null;
1025    }
1026    
1027    private void _updateRelations(WorkflowAwareContent content, Map<String, Set<String>> contentRelationsByAttribute, Logger logger)
1028    {
1029        // Compute the view
1030        View view = View.of(content.getModel(), contentRelationsByAttribute.keySet().toArray(new String[contentRelationsByAttribute.size()]));
1031        
1032        // Compute values
1033        Map<String, Object> values = new HashMap<>();
1034        for (String attributeName : contentRelationsByAttribute.keySet())
1035        {
1036            // Add the content relations to the existing ones
1037            List<String> attributeValue = _getContentAttributeValue(content, attributeName);
1038            contentRelationsByAttribute.get(attributeName)
1039                .stream()
1040                .filter(id -> !attributeValue.contains(id))
1041                .forEach(attributeValue::add);
1042            
1043            values.put(attributeName, attributeValue.toArray(new String[attributeValue.size()]));
1044        }
1045        
1046        // Compute not synchronized contents
1047        Set<String> notSynchronizedContentIds = contentRelationsByAttribute.values()
1048                .stream()
1049                .flatMap(Collection::stream)
1050                .filter(id -> !_synchronizedContents.contains(id))
1051                .collect(Collectors.toSet());
1052        
1053        try
1054        {
1055            _editContent(content, Optional.of(view), values, Map.of(), false, notSynchronizedContentIds, logger);
1056        }
1057        catch (WorkflowException e)
1058        {
1059            _nbError++;
1060            logger.error("The content '{}' cannot be links edited (workflow action)", content, e);
1061        }
1062    }
1063    
1064    private List<String> _getContentAttributeValue(Content content, String attributeName)
1065    {
1066        return Optional.of(attributeName)
1067            .map(content::<ContentValue[]>getValue)
1068            .map(Stream::of)
1069            .orElseGet(Stream::empty)
1070            .map(ContentValue::getContentId)
1071            .collect(Collectors.toList());
1072    }
1073    
1074    private void _updateWorkflowStatus(Logger logger)
1075    {
1076        // Validate contents -> only on newly imported contents
1077        if (validateAfterImport())
1078        {
1079            for (String contentId : _importedContents.keySet())
1080            {
1081                WorkflowAwareContent content = _resolver.resolveById(contentId);
1082                Integer validationActionId = _importedContents.get(contentId);
1083                if (validationActionId > 0)
1084                {
1085                    validateContent(content, validationActionId, logger);
1086                }
1087            }
1088        }
1089    }
1090    
1091    @Override
1092    public boolean handleRightAssignmentContext()
1093    {
1094        // Rights on ODF contents are handled by ODFRightAssignmentContext
1095        return false;
1096    }
1097    
1098    private record ComputedChildren(List<Object> childrenIds, Map<String, Map<String, List<Object>>> newObjects, List<EnfantsStructure> nextObjects) { /* empty*/ }
1099}