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 */
016package org.ametys.plugins.odfsync.pegase;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.nio.charset.StandardCharsets;
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.Objects;
030import java.util.Optional;
031import java.util.Set;
032import java.util.function.Predicate;
033import java.util.stream.Collectors;
034import java.util.stream.Stream;
035
036import org.apache.avalon.framework.configuration.Configuration;
037import org.apache.avalon.framework.configuration.ConfigurationException;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.commons.io.IOUtils;
041import org.apache.commons.lang3.StringUtils;
042import org.apache.http.HttpEntity;
043import org.apache.http.NameValuePair;
044import org.apache.http.StatusLine;
045import org.apache.http.client.HttpResponseException;
046import org.apache.http.client.config.RequestConfig;
047import org.apache.http.client.entity.UrlEncodedFormEntity;
048import org.apache.http.client.methods.CloseableHttpResponse;
049import org.apache.http.client.methods.HttpGet;
050import org.apache.http.client.methods.HttpPost;
051import org.apache.http.client.methods.HttpUriRequest;
052import org.apache.http.impl.client.CloseableHttpClient;
053import org.apache.http.impl.client.HttpClientBuilder;
054import org.apache.http.message.BasicNameValuePair;
055import org.slf4j.Logger;
056
057import org.ametys.cms.FilterNameHelper;
058import org.ametys.cms.data.ContentValue;
059import org.ametys.cms.repository.Content;
060import org.ametys.cms.repository.ContentQueryHelper;
061import org.ametys.cms.repository.LanguageExpression;
062import org.ametys.cms.repository.ModifiableContent;
063import org.ametys.cms.repository.WorkflowAwareContent;
064import org.ametys.core.util.JSONUtils;
065import org.ametys.core.util.URIUtils;
066import org.ametys.odf.ProgramItem;
067import org.ametys.odf.catalog.CatalogsManager;
068import org.ametys.odf.course.Course;
069import org.ametys.odf.course.CourseFactory;
070import org.ametys.odf.courselist.CourseList;
071import org.ametys.odf.courselist.CourseListFactory;
072import org.ametys.odf.program.ContainerFactory;
073import org.ametys.odf.program.ProgramFactory;
074import org.ametys.odf.program.ProgramPart;
075import org.ametys.odf.program.SubProgramFactory;
076import org.ametys.odf.program.TraversableProgramPart;
077import org.ametys.odf.workflow.AbstractCreateODFContentFunction;
078import org.ametys.plugins.contentio.synchronize.AbstractSimpleSynchronizableContentsCollection;
079import org.ametys.plugins.odfsync.utils.ContentWorkflowDescription;
080import org.ametys.plugins.repository.AmetysObjectIterable;
081import org.ametys.plugins.repository.query.expression.AndExpression;
082import org.ametys.plugins.repository.query.expression.Expression;
083import org.ametys.plugins.repository.query.expression.Expression.Operator;
084import org.ametys.plugins.repository.query.expression.StringExpression;
085import org.ametys.runtime.config.Config;
086import org.ametys.runtime.i18n.I18nizableText;
087import org.ametys.runtime.model.View;
088
089import com.google.common.collect.ImmutableList;
090import com.opensymphony.workflow.WorkflowException;
091
092/**
093 * SCC for Pegase (COF).
094 */
095public class PegaseSynchronizableContentsCollection extends AbstractSimpleSynchronizableContentsCollection
096{
097    /** The JSON utils */
098    protected JSONUtils _jsonUtils;
099    
100    /** The catalogs manager */
101    protected CatalogsManager _catalogsManager;
102    
103    /** Mapping between metadata and columns */
104    protected Map<String, Map<String, List<String>>> _mappingByContentType;
105    
106    /** Search fields to display */
107    protected Set<String> _searchFields;
108
109    /** List of imported contents */
110    protected Map<String, Integer> _importedContents;
111    
112    /** List of synchronized contents having differences */
113    protected Set<String> _synchronizedContents;
114    
115    /** List of updated contents by relation */
116    protected Set<String> _updatedRelationContents;
117    
118    /** Map to link contents to its children at the end of the process */
119    protected Map<String, Set<String>> _contentsChildren;
120    
121    /** Default language configured for ODF */
122    protected String _odfLang;
123    
124    @Override
125    public void service(ServiceManager manager) throws ServiceException
126    {
127        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
128        _catalogsManager = (CatalogsManager) manager.lookup(CatalogsManager.ROLE);
129        super.service(manager);
130    }
131    
132    @Override
133    public String getIdField()
134    {
135        return "pegaseSyncCode";
136    }
137
138    /**
139     * Get the identifier JSON field.
140     * @return the column id
141     */
142    protected String getIdColumn()
143    {
144        return "id";
145    }
146
147    @SuppressWarnings("unchecked")
148    @Override
149    public Set<String> getLocalAndExternalFields(Map<String, Object> additionalParameters)
150    {
151        return Optional.ofNullable(additionalParameters)
152            .map(params -> params.get("contentTypes"))
153            .filter(List.class::isInstance)
154            .map(cTypes -> (List<String>) cTypes)
155            .filter(Predicate.not(List::isEmpty))
156            .map(l -> l.get(0))
157            .map(_mappingByContentType::get)
158            .map(Map::keySet)
159            .orElse(Set.of());
160    }
161
162    @Override
163    protected Map<String, Object> putIdParameter(String idValue)
164    {
165        Map<String, Object> parameters = new HashMap<>();
166        parameters.put(getIdColumn(), List.of(idValue));
167        return parameters;
168    }
169    
170    @SuppressWarnings("unchecked")
171    @Override
172    protected Map<String, Map<String, Object>> internalSearch(Map<String, Object> parameters, int offset, int limit, List<Object> sort, Logger logger)
173    {
174        Map<String, Map<String, Object>> results = new HashMap<>();
175        
176        try (CloseableHttpClient httpClient = _getHttpClient())
177        {
178            // FIXME If 403 error with header cache-control = no-cacheno-storemax-age=0must-revalidate, automatically get a new token ?
179            String token = getToken(httpClient);
180            boolean importMode = (boolean) parameters.getOrDefault("import", false);
181            
182            List<String> idValues = (List<String>) parameters.computeIfAbsent(getIdColumn(), __ -> new ArrayList<>());
183
184            // Simple search or no defined ID during import (global import)
185            if (idValues.isEmpty())
186            {
187                /* Future proof (not implemented in v1 nor in v2-draft)
188                Map<String, Object> pageable = new HashMap<>();
189                int pageSize = limit - offset;
190                pageable.put("page", offset / limit); Numérotation à partir de 0 ou 1 ?
191                pageable.put("taille", pageSize);
192                pageable.put("tri", sort...);
193                => add to url parameters
194                */
195                
196                String jsonString = _executeGetRequest(httpClient, "/formations", Map.of(), token);
197                
198                // Handle the response
199                List<Object> json = _jsonUtils.convertJsonToList(jsonString);
200                for (Object obj : json)
201                {
202                    Map<String, Object> programDescription = (Map<String, Object>) obj;
203                    String idValue = (String) programDescription.get(getIdColumn());
204
205                    // If not an import, fill the results
206                    if (!importMode)
207                    {
208                        Map<String, Object> result = results.computeIfAbsent(idValue, __ -> new HashMap<>());
209                        for (String keptField : _searchFields)
210                        {
211                            result.put(keptField, programDescription.get(keptField));
212                        }
213                        result.put(SCC_UNIQUE_ID, idValue);
214                    }
215                    else
216                    {
217                        idValues.add(idValue);
218                    }
219                }
220            }
221            
222            // If import, get the tree
223            if (importMode)
224            {
225                Set<String> handledObjects = new HashSet<>();
226                for (String idValue : idValues)
227                {
228                    String jsonValue = _executeGetRequest(httpClient, "/arbres/" + idValue, Map.of(), token);
229
230                    // Handle the response
231                    // First iteration on the root
232                    List<Map<String, Object>> children = new ArrayList<>();
233                    children.add((Map<String, Object>) _jsonUtils.convertJsonToMap(jsonValue).get("racine"));
234                    
235                    while (!children.isEmpty())
236                    {
237                        Map<String, Object> child = children.remove(0);
238                        String childCode = (String) child.get("code");
239                        if (handledObjects.add(childCode))
240                        {
241                            Map<String, List<Object>> mappedChildObject = _jsonToObject(child);
242                            // Add children to the list to iterate on it
243                            children.addAll((List<Map<String, Object>>) (Object) mappedChildObject.remove("jsonChildren"));
244                            // Add mapped object to the results
245                            results.put((String) mappedChildObject.remove(getIdField()).get(0), (Map<String, Object>) (Object) mappedChildObject);
246                        }
247                    }
248                }
249            }
250        }
251        catch (Exception e)
252        {
253            throw new RuntimeException("Error while getting remote values", e);
254        }
255        return results;
256    }
257    
258    @Override
259    public List<ModifiableContent> importContent(String idValue, Map<String, Object> importParams, Logger logger) throws Exception
260    {
261        Map<String, Object> parameters = putIdParameter(idValue);
262        return _importOrSynchronizeContents(parameters, true, logger);
263    }
264    
265    @Override
266    public void synchronizeContent(ModifiableContent content, Logger logger) throws Exception
267    {
268        String idValue = content.getValue(getIdField());
269        Map<String, Object> parameters = putIdParameter(idValue);
270        _importOrSynchronizeContents(parameters, true, logger);
271    }
272    
273    @Override
274    protected List<ModifiableContent> _importOrSynchronizeContents(Map<String, Object> searchParams, boolean forceImport, Logger logger)
275    {
276        _importedContents = new HashMap<>();
277        _synchronizedContents = new HashSet<>();
278        _updatedRelationContents = new HashSet<>();
279        _contentsChildren = new HashMap<>();
280        
281        List<ModifiableContent> contents =  super._importOrSynchronizeContents(searchParams, forceImport, logger);
282        _updateRelations(logger);
283        _updateWorkflowStatus(logger);
284        
285        _importedContents = null;
286        _synchronizedContents = null;
287        _updatedRelationContents = null;
288        _contentsChildren = null;
289        
290        return contents;
291    }
292    
293    private void _updateRelations(Logger logger)
294    {
295        for (String contentId : _contentsChildren.keySet())
296        {
297            WorkflowAwareContent content = _resolver.resolveById(contentId);
298            Set<String> childrenCodes = _contentsChildren.get(contentId);
299            String contentLanguage = content.getLanguage();
300            String contentCatalog = content.getValue("catalog");
301            Map<String, Set<String>> childrenByAttributeName = new HashMap<>();
302            
303            for (String childCode : childrenCodes)
304            {
305                String idSync = _buildSynCodeFromCode(childCode);
306                
307                Expression expression = new AndExpression(
308                    _sccHelper.getCollectionExpression(getId()),
309                    new StringExpression(getIdField(), Operator.EQ, idSync),
310                    new StringExpression("catalog", Operator.EQ, contentCatalog),
311                    new LanguageExpression(Operator.EQ, contentLanguage)
312                );
313                
314                ModifiableContent childContent = _resolver.<ModifiableContent>query(ContentQueryHelper.getContentXPathQuery(expression))
315                    .stream()
316                    .findFirst()
317                    .orElse(null);
318                
319                if (childContent == null)
320                {
321                    logger.warn("Content with code '{}' in {} on catalog '{}' was not found in the repository to update relations with content [{}] '{}' ({}).", idSync, contentLanguage, contentCatalog, content.getValue(getIdField()), content.getTitle(), contentCatalog);
322                }
323                else
324                {
325                    String attributesName = _getChildAttributeName(content, childContent);
326                    if (attributesName != null)
327                    {
328                        Set<String> children = childrenByAttributeName.computeIfAbsent(attributesName, __ -> new LinkedHashSet<>());
329                        children.add(childContent.getId());
330                    }
331                    else
332                    {
333                        logger.warn("The child content [{}] '{}' of type '{}' is not compatible with parent content [{}] '{}' of type '{}'.", idSync, childContent.getTitle(), childContent.getTypes()[0], content.getValue(getIdField()), content.getTitle(), content.getTypes()[0]);
334                    }
335                }
336            }
337            
338            _updateRelations(content, childrenByAttributeName, logger);
339        }
340        
341    }
342    
343    private String _getChildAttributeName(Content parentContent, Content childContent)
344    {
345        if (childContent instanceof Course && parentContent instanceof CourseList)
346        {
347            return CourseList.CHILD_COURSES;
348        }
349        
350        if (parentContent instanceof Course && childContent instanceof CourseList)
351        {
352            return Course.CHILD_COURSE_LISTS;
353        }
354        
355        if (parentContent instanceof TraversableProgramPart && childContent instanceof ProgramPart)
356        {
357            return TraversableProgramPart.CHILD_PROGRAM_PARTS;
358        }
359        
360        return null;
361    }
362    
363    private void _updateRelations(WorkflowAwareContent content, Map<String, Set<String>> contentRelationsByAttribute, Logger logger)
364    {
365        // Compute the view
366        View view = View.of(content.getModel(), contentRelationsByAttribute.keySet().toArray(new String[contentRelationsByAttribute.size()]));
367        
368        // Compute values
369        Map<String, Object> values = new HashMap<>();
370        for (String attributeName : contentRelationsByAttribute.keySet())
371        {
372            // Add the content relations to the existing ones
373            List<String> attributeValue = _getContentAttributeValue(content, attributeName);
374            contentRelationsByAttribute.get(attributeName)
375                .stream()
376                .filter(id -> !attributeValue.contains(id))
377                .forEach(attributeValue::add);
378            
379            values.put(attributeName, attributeValue.toArray(new String[attributeValue.size()]));
380        }
381        
382        // Compute not synchronized contents
383        Set<String> notSynchronizedContentIds = contentRelationsByAttribute.values()
384                .stream()
385                .flatMap(Collection::stream)
386                .filter(id -> !_synchronizedContents.contains(id))
387                .collect(Collectors.toSet());
388        
389        try
390        {
391            _editContent(content, Optional.of(view), values, Map.of(), false, notSynchronizedContentIds, logger);
392        }
393        catch (WorkflowException e)
394        {
395            _nbError++;
396            logger.error("The content '{}' cannot be links edited (workflow action)", content, e);
397        }
398    }
399    
400    private List<String> _getContentAttributeValue(Content content, String attributeName)
401    {
402        return Optional.of(attributeName)
403            .map(content::<ContentValue[]>getValue)
404            .map(Stream::of)
405            .orElseGet(Stream::empty)
406            .map(ContentValue::getContentId)
407            .collect(Collectors.toList());
408    }
409    
410    private void _updateWorkflowStatus(Logger logger)
411    {
412        // Validate contents -> only on newly imported contents
413        if (validateAfterImport())
414        {
415            for (String contentId : _importedContents.keySet())
416            {
417                WorkflowAwareContent content = _resolver.resolveById(contentId);
418                Integer validationActionId = _importedContents.get(contentId);
419                if (validationActionId > 0)
420                {
421                    validateContent(content, validationActionId, logger);
422                }
423            }
424        }
425    }
426
427    @SuppressWarnings("unchecked")
428    private Map<String, List<Object>> _jsonToObject(Map<String, Object> jsonObject)
429    {
430        ContentWorkflowDescription wfDescription = _mapWorkflowDescription((String) jsonObject.get("categorie"));
431        String contentType = wfDescription.getContentType();
432        Map<String, List<String>> contentTypeMapping = _mappingByContentType.get(contentType);
433        
434        Map<String, List<Object>> mappedObject = new HashMap<>();
435        // Add the contentType
436        mappedObject.put("workflowDescription", List.of(wfDescription));
437        
438        for (String attribute : contentTypeMapping.keySet())
439        {
440            List<String> jsonKeys = contentTypeMapping.get(attribute);
441
442            List<Object> values = jsonKeys.stream() // For each column corresponding to the metadata
443                    .map(key -> _getDeepJsonValue(jsonObject, key)) // Map the values
444                    .flatMap(o ->
445                    {
446                        if (o instanceof Collection<?>)
447                        {
448                            return ((Collection<?>) o).stream();
449                        }
450                        return Stream.of(o);
451                    }) // If it's a list of objects, get a flat stream
452                    .filter(Objects::nonNull) // Remove null values
453                    .collect(Collectors.toList()); // Collect it into a List
454            mappedObject.put(attribute, values); // Add the retrieved metadata values list to the contentResult
455        }
456        
457        // Add the ID field
458        // pegaseSyncCode : codeStructure:code::detail/formation/version
459        String syncCode = _buildSynCodeFromCode((String) jsonObject.get("code"));
460        if (ProgramFactory.PROGRAM_CONTENT_TYPE.equals(contentType))
461        {
462            syncCode += _getDeepJsonValue(jsonObject, "detail/formation/version");
463        }
464        mappedObject.put(getIdField(), List.of(syncCode));
465        
466        Map<String, Map<String, Object>> jsonChildren = (Map<String, Map<String, Object>>) jsonObject.get("enfants");
467        // If not GROUPEMENT (CourseList) => split OP (Course) children
468        if (!CourseListFactory.COURSE_LIST_CONTENT_TYPE.equals(contentType))
469        {
470            Map<String, Map<String, Object>> courses = new LinkedHashMap<>();
471            for (String childId : Map.copyOf(jsonChildren).keySet())
472            {
473                Map<String, Object> jsonChild = jsonChildren.get(childId);
474                if ("OP".equals(jsonChild.get("categorie")))
475                {
476                    courses.put(childId, jsonChildren.remove(childId));
477                }
478            }
479
480            // If there are OP (Course) children
481            if (!courses.isEmpty())
482            {
483                // Copy the current JSON object with :
484                //  - category = "GROUPEMENT"
485                //  - code = code + "-list"
486                //  - enfants = courses
487                
488                String courseListSyncCode = jsonObject.get("code") + "-list";
489                
490                Map<String, Object> jsonCourseList = new HashMap<>(jsonObject);
491                jsonCourseList.put("categorie", "GROUPEMENT");
492                jsonCourseList.put("code", courseListSyncCode);
493                jsonCourseList.put("enfants", courses);
494                
495                // Add the copy to jsonChildren
496                jsonChildren.put(courseListSyncCode, jsonCourseList);
497            }
498        }
499        
500        mappedObject.put("jsonChildren", List.copyOf(jsonChildren.values()));
501        
502        Set<? extends Object> children = jsonChildren.keySet();
503        if (!children.isEmpty())
504        {
505            mappedObject.put("children", List.copyOf(children));
506        }
507        
508        return mappedObject;
509    }
510    
511    private String _buildSynCodeFromCode(String code)
512    {
513        return getStructureCode() + ":" + code  + "::";
514    }
515
516    @SuppressWarnings("unchecked")
517    private Object _getDeepJsonValue(Map<String, Object> jsonObject, String jsonKey)
518    {
519        String[] deepKeys = jsonKey.split("/");
520        Optional<Object> optional = Optional.of(jsonObject);
521        for (int i = 0; i < deepKeys.length && optional.isPresent(); i++)
522        {
523            String deepKey = deepKeys[i];
524            optional = optional
525                .map(map -> (Map<String, Object>) map)
526                .map(map -> map.get(deepKey));
527        }
528        
529        return optional.orElse(null);
530    }
531    
532    /**
533     * Get the workflow description for the given category.
534     * @param category The category
535     * @return the matching workflow description
536     */
537    protected ContentWorkflowDescription _mapWorkflowDescription(String category)
538    {
539        switch (category)
540        {
541            case "formation":
542                return ContentWorkflowDescription.PROGRAM_WF_DESCRIPTION;
543            case "OO":
544                return ContentWorkflowDescription.SUBPROGRAM_WF_DESCRIPTION;
545            case "OTT":
546                return ContentWorkflowDescription.CONTAINER_WF_DESCRIPTION;
547            case "GROUPEMENT":
548                return ContentWorkflowDescription.COURSELIST_WF_DESCRIPTION;
549            case "OP":
550                return ContentWorkflowDescription.COURSE_WF_DESCRIPTION;
551            default:
552                // throw an exception
553        }
554        throw new IllegalArgumentException("The category '" + category + "' cannot be converted to an Ametys content type.");
555    }
556
557    @SuppressWarnings("unchecked")
558    @Override
559    protected Map<String, Map<String, List<Object>>> getRemoteValues(Map<String, Object> parameters, Logger logger)
560    {
561        return (Map<String, Map<String, List<Object>>>) (Object) internalSearch(parameters, 0, Integer.MAX_VALUE, null, logger);
562    }
563
564    @Override
565    protected void configureDataSource(Configuration configuration) throws ConfigurationException
566    {
567        _odfLang = Config.getInstance().getValue("odf.programs.lang");
568        
569        // Champs affichés dans la recherche
570        _searchFields = new HashSet<>();
571        _searchFields.add("id");
572        _searchFields.add("code");
573        _searchFields.add("libelleCourt");
574        _searchFields.add("libelleLong");
575        _searchFields.add("codeTypeDiplome");
576        _searchFields.add("codeNiveauFormation");
577        _searchFields.add("codeDomaineFormation");
578        _searchFields.add("codeNatureDiplome");
579        _searchFields.add("codeNiveauDiplome");
580        _searchFields.add("codeMention");
581        _searchFields.add("codeChampFormation");
582        _searchFields.add("codeDiplomeSise");
583        _searchFields.add("niveauSise");
584        _searchFields.add("ects");
585        
586        _mappingByContentType = new HashMap<>();
587        
588        // Program
589        Map<String, List<String>> contentTypeMapping = new HashMap<>();
590        contentTypeMapping.put("title", List.of("libelleLong", "libelle"));
591        contentTypeMapping.put("ects", List.of("ects"));
592        contentTypeMapping.put("educationKind", List.of("detail/formation/typeFormation/code"));
593        contentTypeMapping.put("siseCode", List.of("codeDiplomeSise"));
594        contentTypeMapping.put("presentation", List.of("description"));
595        contentTypeMapping.put("mention", List.of("codeMention"));
596        contentTypeMapping.put("domain", List.of("codeDomaineFormation"));
597        contentTypeMapping.put("programField", List.of("codeChampFormation"));
598        contentTypeMapping.put("educationLevel", List.of("codeNiveauFormation"));
599        contentTypeMapping.put("rncpLevel", List.of("codeNiveauDiplome"));
600        contentTypeMapping.put("degree", List.of("codeTypeDiplome"));
601        _mappingByContentType.put(ProgramFactory.PROGRAM_CONTENT_TYPE, contentTypeMapping);
602        
603        // SubProgram
604        contentTypeMapping = new HashMap<>();
605        contentTypeMapping.put("title", List.of("libelleLong", "libelle"));
606        contentTypeMapping.put("ects", List.of("ects"));
607        contentTypeMapping.put("presentation", List.of("description"));
608        contentTypeMapping.put("educationKind", List.of("type/code"));
609        _mappingByContentType.put(SubProgramFactory.SUBPROGRAM_CONTENT_TYPE, contentTypeMapping);
610
611        // Container
612        contentTypeMapping = new HashMap<>();
613        contentTypeMapping.put("title", List.of("libelleLong", "libelle"));
614        contentTypeMapping.put("ects", List.of("ects"));
615        contentTypeMapping.put("description", List.of("description"));
616        contentTypeMapping.put("nature", List.of("type/code"));
617        contentTypeMapping.put("period", List.of("type/code")); // Pour année en tout cas...
618        contentTypeMapping.put("siseCode", ImmutableList.of("codeDiplomeSise"));
619        _mappingByContentType.put(ContainerFactory.CONTAINER_CONTENT_TYPE, contentTypeMapping);
620        
621        // CourseList
622        contentTypeMapping = new HashMap<>();
623        contentTypeMapping.put("title", List.of("libelleLong", "libelle"));
624        contentTypeMapping.put("plageDeChoix", List.of("plageDeChoix"));
625        contentTypeMapping.put("obligatoire", List.of("obligatoire"));
626        contentTypeMapping.put("min", List.of("plageMin"));
627        contentTypeMapping.put("max", List.of("plageMax"));
628        _mappingByContentType.put(CourseListFactory.COURSE_LIST_CONTENT_TYPE, contentTypeMapping);
629
630        // Course
631        contentTypeMapping = new HashMap<>();
632        contentTypeMapping.put("title", List.of("libelleLong", "libelle"));
633        contentTypeMapping.put("ects", List.of("ects"));
634        contentTypeMapping.put("description", List.of("description"));
635        contentTypeMapping.put("courseType", List.of("type/code"));
636        contentTypeMapping.put("siseCode", ImmutableList.of("codeDiplomeSise"));
637        _mappingByContentType.put(CourseFactory.COURSE_CONTENT_TYPE, contentTypeMapping);
638    }
639
640    @Override
641    protected void configureSearchModel()
642    {
643        // J'avais trouvé ces critères à une époque (et la pagination) mais tout semble avoir disparu. Mais je n'a pas pu les inventer...
644//        _searchModelConfiguration.addCriterion("type", new I18nizableText("odf-sync", "PLUGINS_ODF_PEGASE_CRITERION_TYPE"), "string");
645//        _searchModelConfiguration.addCriterion("texte", new I18nizableText("odf-sync", "PLUGINS_ODF_PEGASE_CRITERION_TEXTE"), "string");
646//        _searchModelConfiguration.addCriterion("code", new I18nizableText("odf-sync", "PLUGINS_ODF_PEGASE_CRITERION_CODE"), "string");
647//        _searchModelConfiguration.addCriterion("libelle", new I18nizableText("odf-sync", "PLUGINS_ODF_PEGASE_CRITERION_LIBELLE"), "string");
648//        _searchModelConfiguration.addCriterion("mutualise", new I18nizableText("odf-sync", "PLUGINS_ODF_PEGASE_CRITERION_MUTUALISE"), "boolean", "edition.boolean-combobox");
649//        _searchModelConfiguration.addCriterion("codeNature", new I18nizableText("odf-sync", "PLUGINS_ODF_PEGASE_CRITERION_CODE_NATURE"), "string");
650        
651        for (String keptField : _searchFields)
652        {
653            _searchModelConfiguration.addColumn(keptField, new I18nizableText(keptField));
654        }
655    }
656
657    /**
658     * Get the catalog for import.
659     * @return the catalog
660     */
661    protected String getCatalog()
662    {
663        return Optional.of(getParameterValues())
664                   .map(params -> params.get("catalog"))
665                   .map(String.class::cast)
666                   .filter(StringUtils::isNotBlank)
667                   .orElseGet(() -> _catalogsManager.getDefaultCatalogName());
668    }
669    
670    /**
671     * Get the Pegase base URL
672     * @return the Pegase base URL
673     */
674    protected String getBaseUrl()
675    {
676        return (String) getParameterValues().get("baseUrl");
677    }
678    
679    /**
680     * Get the structure code from Pegase
681     * @return the structure code
682     */
683    protected String getStructureCode()
684    {
685        return (String) getParameterValues().get("structureCode");
686    }
687
688    /**
689     * Get the token to log to Pegase API
690     * @param httpClient Http client
691     * @return a valid token
692     * @throws IOException if an error occurs
693     */
694    protected String getToken(CloseableHttpClient httpClient) throws IOException
695    {
696        String username = (String) getParameterValues().get("username");
697        String password = (String) getParameterValues().get("password");
698        String url = (String) getParameterValues().get("authUrl");
699
700        List<NameValuePair> urlParams = new ArrayList<>();
701        urlParams.add(new BasicNameValuePair("username", username));
702        urlParams.add(new BasicNameValuePair("password", password));
703        urlParams.add(new BasicNameValuePair("token", "true"));
704        
705        // Prepare a request object
706        HttpPost postRequest = new HttpPost(url);
707        
708        // HTTP parameters
709        postRequest.setEntity(new UrlEncodedFormEntity(urlParams, "UTF-8"));
710        postRequest.setHeader("Content-Type", "application/x-www-form-urlencoded");
711        
712        // Execute the request
713        return _executeHttpRequest(httpClient, postRequest);
714    }
715    
716    private String _executeGetRequest(CloseableHttpClient httpClient, String url, Map<String, String> urlParameters, String token) throws IOException
717    {
718        String completeUrl = URIUtils.encodeURI(getBaseUrl() + "/etablissements/" + getStructureCode() + url, urlParameters);
719        
720        // Prepare a request object
721        HttpGet getRequest = new HttpGet(completeUrl);
722        
723        // HTTP parameters
724        getRequest.setHeader("Authorization", "Bearer " + token);
725        
726        // Execute the request
727        return _executeHttpRequest(httpClient, getRequest);
728    }
729    
730    private String _executeHttpRequest(CloseableHttpClient httpClient, HttpUriRequest httpRequest) throws IOException
731    {
732        httpRequest.setHeader("accept", "application/json");
733        
734        // Execute the request
735        try (CloseableHttpResponse httpResponse = httpClient.execute(httpRequest))
736        {
737            StatusLine statusLine = httpResponse.getStatusLine();
738            if (statusLine.getStatusCode() / 100 != 2)
739            {
740                throw new HttpResponseException(statusLine.getStatusCode(), statusLine.getReasonPhrase());
741            }
742            
743            HttpEntity entity = httpResponse.getEntity();
744            if (entity == null)
745            {
746                throw new IOException("The response entity is empty.");
747            }
748            
749            try (InputStream is = entity.getContent())
750            {
751                return IOUtils.toString(is, StandardCharsets.UTF_8);
752            }
753        }
754    }
755
756    private CloseableHttpClient _getHttpClient()
757    {
758        RequestConfig requestConfig = RequestConfig.custom().build();
759        return HttpClientBuilder.create()
760                .setDefaultRequestConfig(requestConfig)
761                .useSystemProperties()
762                .build();
763    }
764    
765    /**
766     * Get or create the content defined by the given parameters.
767     * @param lang The content language
768     * @param idValue The synchronization code
769     * @param remoteValues The remote values
770     * @param forceImport <code>true</code> to force import (only on single import or unlimited global synchronization)
771     * @param logger The logger
772     * @return the content
773     * @throws Exception if an error occurs
774     */
775    protected ModifiableContent _getOrCreateContent(String lang, String idValue, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger) throws Exception
776    {
777        ContentWorkflowDescription wfDescription = (ContentWorkflowDescription) remoteValues.get("workflowDescription").get(0);
778        ModifiableContent content = _getContent(lang, idValue, wfDescription.getContentType());
779        if (content == null && (forceImport || !synchronizeExistingContentsOnly()))
780        {
781            // Calculate contentTitle
782            String contentTitle = Optional.of(remoteValues)
783                .map(v -> v.get("title"))
784                .map(List::stream)
785                .orElseGet(Stream::empty)
786                .filter(String.class::isInstance)
787                .map(String.class::cast)
788                .filter(StringUtils::isNotEmpty)
789                .findFirst()
790                .orElse(idValue);
791            
792            String contentName = FilterNameHelper.filterName(_contentPrefix + "-" + contentTitle + "-" + lang);
793            
794            Map<String, Object> inputs = new HashMap<>();
795            String catalog = getCatalog();
796            if (catalog != null)
797            {
798                inputs.put(AbstractCreateODFContentFunction.CONTENT_CATALOG_KEY, catalog);
799            }
800            
801            Map<String, Object> resultMap = _contentWorkflowHelper.createContent(
802                    wfDescription.getWorkflowName(),
803                    wfDescription.getInitialActionId(),
804                    contentName,
805                    contentTitle,
806                    new String[] {wfDescription.getContentType()},
807                    null,
808                    lang,
809                    null,
810                    null,
811                    inputs);
812            
813            if ((boolean) resultMap.getOrDefault("error", false))
814            {
815                _nbError++;
816            }
817            
818            content = (ModifiableContent) resultMap.get(Content.class.getName());
819
820            if (content != null)
821            {
822                _sccHelper.updateSCCProperty(content, getId());
823                
824                // Set sync code
825                content.setValue(getIdField(), idValue);
826                
827                content.saveChanges();
828                _importedContents.put(content.getId(), wfDescription.getValidationActionId());
829                _nbCreatedContents++;
830            }
831        }
832        return content;
833    }
834    
835    /**
836     * Get the content from the synchronization code, the lang, the catalog and the content type.
837     * @param lang The lang
838     * @param syncCode The synchronization code
839     * @param contentType The content type
840     * @return the retrieved content
841     */
842    protected ModifiableContent _getContent(String lang, String syncCode, String contentType)
843    {
844        List<Expression> expList = _getExpressionsList(lang, syncCode, contentType);
845        AndExpression andExp = new AndExpression(expList.toArray(new Expression[expList.size()]));
846        String xPathQuery = ContentQueryHelper.getContentXPathQuery(andExp);
847
848        AmetysObjectIterable<ModifiableContent> contents = _resolver.query(xPathQuery);
849
850        if (contents.getSize() > 0)
851        {
852            return contents.iterator().next();
853        }
854        
855        return null;
856    }
857    
858    @Override
859    protected Optional<ModifiableContent> _importOrSynchronizeContent(String idValue, String lang, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger)
860    {
861        try
862        {
863            ModifiableContent content = _getOrCreateContent(lang, idValue, remoteValues, forceImport, logger);
864            if (content != null)
865            {
866                return Optional.of(_synchronizeContent(content, remoteValues, logger));
867            }
868        }
869        catch (Exception e)
870        {
871            _nbError++;
872            logger.error("An error occurred while importing or synchronizing content", e);
873        }
874        
875        return Optional.empty();
876    }
877    
878    @SuppressWarnings("unchecked")
879    @Override
880    protected ModifiableContent _synchronizeContent(ModifiableContent content, Map<String, List<Object>> remoteValues, Logger logger) throws Exception
881    {
882        super._synchronizeContent(content, remoteValues, logger);
883        // Add children to the list to handle later to add relations
884        if (remoteValues.containsKey("children"))
885        {
886            Set<String> children = _contentsChildren.computeIfAbsent(content.getId(), __ -> new LinkedHashSet<>());
887            children.addAll((List<String>) (Object) remoteValues.get("children"));
888        }
889        return content;
890    }
891    
892    @Override
893    protected boolean _fillContent(Map<String, List<Object>> remoteValues, ModifiableContent content, Map<String, Object> additionalParameters, boolean create, Logger logger) throws Exception
894    {
895        _synchronizedContents.add(content.getId());
896        return super._fillContent(remoteValues, content, additionalParameters, create, logger);
897    }
898    
899    @Override
900    public List<String> getLanguages()
901    {
902        return List.of(_odfLang);
903    }
904
905    @Override
906    protected List<Expression> _getExpressionsList(String lang, String idValue, String contentType)
907    {
908        List<Expression> expList = super._getExpressionsList(lang, idValue, contentType);
909        String catalog = getCatalog();
910        if (catalog != null)
911        {
912            expList.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
913        }
914        return expList;
915    }
916    
917    @Override
918    protected Map<String, Map<String, List<Object>>> getTransformedRemoteValues(Map<String, Object> searchParameters, Logger logger)
919    {
920        searchParameters.put("import", true);
921        return super.getTransformedRemoteValues(searchParameters, logger);
922    }
923    
924    @Override
925    protected Map<String, Object> _transformRemoteValuesCardinality(Map<String, List<Object>> remoteValues, String obsoleteContentTypeId)
926    {
927        String realContentTypeId = Optional.of(remoteValues)
928                .map(v -> v.get("workflowDescription"))
929                .map(l -> l.get(0))
930                .map(ContentWorkflowDescription.class::cast)
931                .map(ContentWorkflowDescription::getContentType)
932                .orElse(null);
933        return super._transformRemoteValuesCardinality(remoteValues, realContentTypeId);
934    }
935}