001/*
002 *  Copyright 2017 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.odf;
017
018import java.util.ArrayList;
019import java.util.Arrays;
020import java.util.Collection;
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.HashSet;
024import java.util.LinkedHashSet;
025import java.util.List;
026import java.util.Map;
027import java.util.Objects;
028import java.util.Optional;
029import java.util.Set;
030import java.util.function.Function;
031import java.util.function.Predicate;
032import java.util.stream.Collectors;
033import java.util.stream.Stream;
034
035import org.apache.avalon.framework.activity.Initializable;
036import org.apache.avalon.framework.component.Component;
037import org.apache.avalon.framework.context.Context;
038import org.apache.avalon.framework.context.ContextException;
039import org.apache.avalon.framework.context.Contextualizable;
040import org.apache.avalon.framework.service.ServiceException;
041import org.apache.avalon.framework.service.ServiceManager;
042import org.apache.avalon.framework.service.Serviceable;
043import org.apache.cocoon.components.ContextHelper;
044import org.apache.cocoon.environment.Request;
045import org.apache.commons.lang3.ArrayUtils;
046import org.apache.commons.lang3.StringUtils;
047import org.apache.commons.lang3.tuple.Pair;
048
049import org.ametys.cms.CmsConstants;
050import org.ametys.cms.content.ContentHelper;
051import org.ametys.cms.content.references.OutgoingReferences;
052import org.ametys.cms.content.references.OutgoingReferencesExtractor;
053import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
054import org.ametys.cms.data.ContentDataHelper;
055import org.ametys.cms.repository.Content;
056import org.ametys.cms.repository.ContentDAO;
057import org.ametys.cms.repository.ContentQueryHelper;
058import org.ametys.cms.repository.ContentTypeExpression;
059import org.ametys.cms.repository.DefaultContent;
060import org.ametys.cms.repository.LanguageExpression;
061import org.ametys.cms.repository.ModifiableContent;
062import org.ametys.cms.repository.ModifiableWorkflowAwareContent;
063import org.ametys.cms.rights.ContentRightAssignmentContext;
064import org.ametys.core.cache.AbstractCacheManager;
065import org.ametys.core.cache.Cache;
066import org.ametys.core.right.RightManager;
067import org.ametys.core.ui.Callable;
068import org.ametys.odf.content.code.UniqueCodeGenerator;
069import org.ametys.odf.content.code.UniqueCodeGeneratorExtensionPoint;
070import org.ametys.odf.course.Course;
071import org.ametys.odf.course.CourseContainer;
072import org.ametys.odf.course.ShareableCourseHelper;
073import org.ametys.odf.courselist.CourseList;
074import org.ametys.odf.courselist.CourseList.ChoiceType;
075import org.ametys.odf.courselist.CourseListContainer;
076import org.ametys.odf.coursepart.CoursePart;
077import org.ametys.odf.coursepart.CoursePartFactory;
078import org.ametys.odf.data.EducationalPath;
079import org.ametys.odf.data.type.EducationalPathRepositoryElementType;
080import org.ametys.odf.enumeration.OdfReferenceTableEntry;
081import org.ametys.odf.enumeration.OdfReferenceTableHelper;
082import org.ametys.odf.orgunit.OrgUnit;
083import org.ametys.odf.orgunit.OrgUnitFactory;
084import org.ametys.odf.orgunit.RootOrgUnitProvider;
085import org.ametys.odf.person.Person;
086import org.ametys.odf.program.AbstractProgram;
087import org.ametys.odf.program.AbstractTraversableProgramPart;
088import org.ametys.odf.program.Container;
089import org.ametys.odf.program.Program;
090import org.ametys.odf.program.ProgramFactory;
091import org.ametys.odf.program.ProgramPart;
092import org.ametys.odf.program.SubProgram;
093import org.ametys.odf.program.TraversableProgramPart;
094import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
095import org.ametys.plugins.repository.AmetysObject;
096import org.ametys.plugins.repository.AmetysObjectExistsException;
097import org.ametys.plugins.repository.AmetysObjectIterable;
098import org.ametys.plugins.repository.AmetysObjectIterator;
099import org.ametys.plugins.repository.AmetysObjectResolver;
100import org.ametys.plugins.repository.AmetysRepositoryException;
101import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
102import org.ametys.plugins.repository.RepositoryConstants;
103import org.ametys.plugins.repository.UnknownAmetysObjectException;
104import org.ametys.plugins.repository.collection.AmetysObjectCollection;
105import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeater;
106import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeaterEntry;
107import org.ametys.plugins.repository.data.holder.impl.DataHolderHelper;
108import org.ametys.plugins.repository.jcr.DefaultAmetysObject;
109import org.ametys.plugins.repository.model.RepeaterDefinition;
110import org.ametys.plugins.repository.model.RepositoryDataContext;
111import org.ametys.plugins.repository.query.QueryHelper;
112import org.ametys.plugins.repository.query.SortCriteria;
113import org.ametys.plugins.repository.query.expression.AndExpression;
114import org.ametys.plugins.repository.query.expression.Expression;
115import org.ametys.plugins.repository.query.expression.Expression.Operator;
116import org.ametys.plugins.repository.query.expression.OrExpression;
117import org.ametys.plugins.repository.query.expression.StringExpression;
118import org.ametys.runtime.config.Config;
119import org.ametys.runtime.i18n.I18nizableText;
120import org.ametys.runtime.model.ModelHelper;
121import org.ametys.runtime.model.ModelItem;
122import org.ametys.runtime.model.type.DataContext;
123import org.ametys.runtime.plugin.component.AbstractLogEnabled;
124
125import com.opensymphony.workflow.WorkflowException;
126
127/**
128 * Helper for ODF contents
129 *
130 */
131public class ODFHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable, Initializable
132{
133    /** The component role. */
134    public static final String ROLE = ODFHelper.class.getName();
135    
136    /** Request attribute to get the "Live" version of contents */
137    public static final String REQUEST_ATTRIBUTE_VALID_LABEL = "live-version";
138    
139    /** The default id of initial workflow action */
140    protected static final int __INITIAL_WORKFLOW_ACTION_ID = 0;
141    
142    private static final String __ANCESTORS_CACHE = ODFHelper.class.getName() + "$ancestors";
143    
144    private static final String __PARENT_PROGRAM_ITEMS_CACHE = ODFHelper.class.getName() + "$parentProgramItems";
145    
146    private static final String __ODF_PLUGIN_NAME = "odf";
147    
148    /** Ametys object resolver */
149    protected AmetysObjectResolver _resolver;
150    /** The content types manager */
151    protected ContentTypeExtensionPoint _cTypeEP;
152    /** Root orgunit */
153    protected RootOrgUnitProvider _ouRootProvider;
154    /** Helper for shareable course */
155    protected ShareableCourseHelper _shareableCourseHelper;
156    /** The Avalon context */
157    protected Context _context;
158    /** The cache manager */
159    protected AbstractCacheManager _cacheManager;
160    /** The content helper */
161    protected ContentHelper _contentHelper;
162    /** The outgoing references extractor */
163    protected OutgoingReferencesExtractor _outgoingReferencesExtractor;
164    /** The content DAO **/
165    protected ContentDAO _contentDAO;
166    /** The helper for reference tables from ODF */
167    protected OdfReferenceTableHelper _refTableHelper;
168    /** The right manager */
169    protected RightManager _rightManager;
170    
171    private UniqueCodeGeneratorExtensionPoint _codeGeneratorEP;
172    private Optional<String> _yearId = Optional.empty();
173
174    
175    public void contextualize(Context context) throws ContextException
176    {
177        _context = context;
178    }
179    
180    @Override
181    public void service(ServiceManager manager) throws ServiceException
182    {
183        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
184        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
185        _ouRootProvider = (RootOrgUnitProvider) manager.lookup(RootOrgUnitProvider.ROLE);
186        _shareableCourseHelper = (ShareableCourseHelper) manager.lookup(ShareableCourseHelper.ROLE);
187        _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE);
188        _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE);
189        _outgoingReferencesExtractor = (OutgoingReferencesExtractor) manager.lookup(OutgoingReferencesExtractor.ROLE);
190        _contentDAO = (ContentDAO) manager.lookup(ContentDAO.ROLE);
191        _codeGeneratorEP = (UniqueCodeGeneratorExtensionPoint) manager.lookup(UniqueCodeGeneratorExtensionPoint.ROLE);
192        _refTableHelper = (OdfReferenceTableHelper) manager.lookup(OdfReferenceTableHelper.ROLE);
193        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
194    }
195    
196    public void initialize() throws Exception
197    {
198        _cacheManager.createRequestCache(__ANCESTORS_CACHE,
199                new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_ODF_ANCESTORS_LABEL"),
200                new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_ODF_ANCESTORS_DESCRIPTION"),
201                false);
202        
203        _cacheManager.createRequestCache(__PARENT_PROGRAM_ITEMS_CACHE,
204                new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_ODF_PARENT_PROGRAM_ITEMS_LABEL"),
205                new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_ODF_PARENT_PROGRAM_ITEMS_DESCRIPTION"),
206                false);
207    }
208    
209    /**
210     * Determines if legacy sax of program structure should be used
211     * @param programItem the program item beeing rendering
212     * @return true if legacy sax of program structure should be used
213     */
214    public boolean useLegacyProgramStructure(ProgramItem programItem)
215    {
216        return true;
217    }
218    
219    /**
220     * Generate an unique code for a given ODF content type
221     * @param cTypeId The id of ODF content type
222     * @return The unique code
223     */
224    public synchronized String generateUniqueCode(String cTypeId)
225    {
226        String codeGenId = Config.getInstance().getValue("odf.code.generator", true, "odf-code-incremental");
227        UniqueCodeGenerator codeGen = _codeGeneratorEP.getExtension(codeGenId);
228        
229        return codeGen.generateUniqueCode(cTypeId);
230    }
231    
232    /**
233     * Gets the root for ODF contents
234     * @return the root for ODF contents
235     */
236    public AmetysObjectCollection getRootContent()
237    {
238        return getRootContent(false);
239    }
240    
241    /**
242     * Gets the root for ODF contents
243     * @param create <code>true</code> to create automatically the root when missing.
244     * @return the root for ODF contents
245     */
246    public AmetysObjectCollection getRootContent(boolean create)
247    {
248        ModifiableTraversableAmetysObject pluginsNode = _resolver.resolveByPath("/ametys:plugins/");
249        
250        boolean needSave = false;
251        if (!pluginsNode.hasChild(__ODF_PLUGIN_NAME))
252        {
253            if (create)
254            {
255                pluginsNode.createChild(__ODF_PLUGIN_NAME, "ametys:unstructured");
256                needSave = true;
257            }
258            else
259            {
260                throw new UnknownAmetysObjectException("Node '/ametys:plugins/" + __ODF_PLUGIN_NAME + "' is missing");
261            }
262        }
263        
264        ModifiableTraversableAmetysObject pluginNode = pluginsNode.getChild(__ODF_PLUGIN_NAME);
265        if (!pluginNode.hasChild(RepositoryConstants.NAMESPACE_PREFIX + ":contents"))
266        {
267            if (create)
268            {
269                pluginNode.createChild(RepositoryConstants.NAMESPACE_PREFIX + ":contents", "ametys:collection");
270                needSave = true;
271            }
272            else
273            {
274                throw new UnknownAmetysObjectException("Node '/ametys:plugins/" + __ODF_PLUGIN_NAME + "/ametys:contents' is missing");
275            }
276        }
277        
278        if (needSave)
279        {
280            pluginsNode.saveChanges();
281        }
282        
283        return pluginNode.getChild(RepositoryConstants.NAMESPACE_PREFIX + ":contents");
284    }
285    
286    /**
287     * Get the {@link ProgramItem}s matching the given arguments
288     * @param cTypeId The id of content type. Can be null to get program's items whatever their content type.
289     * @param code The code. Can be null to get program's items regardless of their code
290     * @param catalogName The search catalog. Can be null to get program's items regardless the catalog they belong to.
291     * @param lang The search language. Can be null to get program's items regardless of their language
292     * @param <C> The content return type
293     * @return The matching program items
294     */
295    public <C extends Content> AmetysObjectIterable<C> getProgramItems(String cTypeId, String code, String catalogName, String lang)
296    {
297        return getProgramItems(cTypeId, code, catalogName, lang, null, null);
298    }
299    
300    /**
301     * Get the {@link ProgramItem}s matching the given arguments
302     * @param cTypeIds The id of content types. Can be empty to get program's items whatever their content type.
303     * @param code The code. Can be null to get program's items regardless of their code
304     * @param catalogName The search catalog. Can be null to get program's items regardless the catalog they belong to.
305     * @param lang The search language. Can be null to get program's items regardless of their language
306     * @param <C> The content return type
307     * @return The matching program items
308     */
309    public <C extends Content> AmetysObjectIterable<C> getProgramItems(Collection<String> cTypeIds, String code, String catalogName, String lang)
310    {
311        return getProgramItems(cTypeIds, code, catalogName, lang, null, null);
312    }
313    
314    /**
315     * Get the {@link ProgramItem}s matching the given arguments
316     * @param cTypeId The id of content type. Can be null to get program's items whatever their content type.
317     * @param code The code. Can be null to get program's items regardless of their code
318     * @param catalogName The search catalog. Can be null to get program's items regardless the catalog they belong to.
319     * @param lang The search language. Can be null to get program's items regardless of their language
320     * @param additionnalExpr An additional expression for filtering result. Can be null
321     * @param sortCriteria criteria for sorting results
322     * @param <C> The content return type
323     * @return The matching program items
324     */
325    public <C extends Content> AmetysObjectIterable<C> getProgramItems(String cTypeId, String code, String catalogName, String lang, Expression additionnalExpr, SortCriteria sortCriteria)
326    {
327        return getProgramItems(cTypeId != null ? Collections.singletonList(cTypeId) : Collections.EMPTY_LIST, code, catalogName, lang, additionnalExpr, sortCriteria);
328    }
329    
330    /**
331     * Get the {@link ProgramItem}s matching the given arguments
332     * @param cTypeIds The id of content types. Can be empty to get program's items whatever their content type.
333     * @param code The code. Can be null to get program's items regardless of their code
334     * @param catalogName The search catalog. Can be null to get program's items regardless the catalog they belong to.
335     * @param lang The search language. Can be null to get program's items regardless of their language
336     * @param additionnalExpr An additional expression for filtering result. Can be null
337     * @param sortCriteria criteria for sorting results
338     * @param <C> The content return type
339     * @return The matching program items
340     */
341    public <C extends Content> AmetysObjectIterable<C> getProgramItems(Collection<String> cTypeIds, String code, String catalogName, String lang, Expression additionnalExpr, SortCriteria sortCriteria)
342    {
343        List<Expression> exprs = new ArrayList<>();
344        
345        if (!cTypeIds.isEmpty())
346        {
347            exprs.add(new ContentTypeExpression(Operator.EQ, cTypeIds.toArray(new String[cTypeIds.size()])));
348        }
349        if (StringUtils.isNotEmpty(code))
350        {
351            exprs.add(new StringExpression(ProgramItem.CODE, Operator.EQ, code));
352        }
353        if (StringUtils.isNotEmpty(catalogName))
354        {
355            exprs.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalogName));
356        }
357        if (StringUtils.isNotEmpty(lang))
358        {
359            exprs.add(new LanguageExpression(Operator.EQ, lang));
360        }
361        if (additionnalExpr != null)
362        {
363            exprs.add(additionnalExpr);
364        }
365        
366        Expression expr = new AndExpression(exprs.toArray(new Expression[exprs.size()]));
367        
368        String xpathQuery = ContentQueryHelper.getContentXPathQuery(expr, sortCriteria);
369        return _resolver.query(xpathQuery);
370    }
371    
372    /**
373     * Get the equivalent {@link CoursePart} of the source {@link CoursePart} in given catalog and language
374     * @param srcCoursePart The source course part
375     * @param catalogName The name of catalog to search into
376     * @param lang The search language
377     * @return The equivalent program item or <code>null</code> if not exists
378     */
379    public CoursePart getCoursePart(CoursePart srcCoursePart, String catalogName, String lang)
380    {
381        return getODFContent(CoursePartFactory.COURSE_PART_CONTENT_TYPE, srcCoursePart.getCode(), catalogName, lang);
382    }
383    
384    /**
385     * Get the equivalent {@link ProgramItem} of the source {@link ProgramItem} in given catalog and language
386     * @param <T> The type of returned object, it have to be a subclass of {@link ProgramItem}
387     * @param srcProgramItem The source program item
388     * @param catalogName The name of catalog to search into
389     * @param lang The search language
390     * @return The equivalent program item or <code>null</code> if not exists
391     */
392    public <T extends ProgramItem> T getProgramItem(T srcProgramItem, String catalogName, String lang)
393    {
394        return getODFContent(((Content) srcProgramItem).getTypes()[0], srcProgramItem.getCode(), catalogName, lang);
395    }
396    
397    /**
398     * Get the equivalent {@link Content} having the same code in given catalog and language
399     * @param <T> The type of returned object, it have to be a subclass of {@link AmetysObject}
400     * @param contentType The content type to search for
401     * @param odfContentCode The code of the ODF content
402     * @param catalogName The name of catalog to search into
403     * @param lang The search language
404     * @return The equivalent content or <code>null</code> if not exists
405     */
406    public <T extends AmetysObject> T getODFContent(String contentType, String odfContentCode, String catalogName, String lang)
407    {
408        Expression contentTypeExpr = new ContentTypeExpression(Operator.EQ, contentType);
409        Expression langExpr = new LanguageExpression(Operator.EQ, lang);
410        Expression catalogExpr = new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalogName);
411        Expression codeExpr = new StringExpression(ProgramItem.CODE, Operator.EQ, odfContentCode);
412        
413        Expression expr = new AndExpression(contentTypeExpr, langExpr, catalogExpr, codeExpr);
414        
415        String xpathQuery = ContentQueryHelper.getContentXPathQuery(expr);
416        AmetysObjectIterable<T> contents = _resolver.query(xpathQuery);
417        AmetysObjectIterator<T> contentsIt = contents.iterator();
418        if (contentsIt.hasNext())
419        {
420            return contentsIt.next();
421        }
422        
423        return null;
424    }
425    
426    /**
427     * Get the child program items of a {@link ProgramItem}
428     * @param programItem The program item
429     * @return The child program items
430     */
431    public List<ProgramItem> getChildProgramItems(ProgramItem programItem)
432    {
433        List<ProgramItem> children = new ArrayList<>();
434        
435        if (programItem instanceof TraversableProgramPart programPart)
436        {
437            children.addAll(programPart.getProgramPartChildren());
438        }
439        
440        if (programItem instanceof CourseContainer courseContainer)
441        {
442            children.addAll(courseContainer.getCourses());
443        }
444        
445        if (programItem instanceof Course course)
446        {
447            children.addAll(course.getCourseLists());
448        }
449        
450        return children;
451    }
452
453    /**
454     * Get the child subprograms of a {@link ProgramPart}
455     * @param programPart The program part
456     * @return The child subprograms
457     */
458    public Set<SubProgram> getChildSubPrograms(ProgramPart programPart)
459    {
460        Set<SubProgram> subPrograms = new HashSet<>();
461        
462        if (programPart instanceof TraversableProgramPart traversableProgram)
463        {
464            if (programPart instanceof SubProgram subProgram)
465            {
466                subPrograms.add(subProgram);
467            }
468            traversableProgram.getProgramPartChildren().forEach(child -> subPrograms.addAll(getChildSubPrograms(child)));
469        }
470
471        return subPrograms;
472    }
473    
474    /**
475     * Gets (recursively) parent containers of this program item.
476     * @param programItem The program item
477     * @return parent containers of this program item.
478     */
479    public Set<Container> getParentContainers(ProgramItem programItem)
480    {
481        return getParentContainers(programItem, false);
482    }
483    
484    /**
485     * Gets (recursively) parent containers of this program item.
486     * @param programItem The program item
487     * @param continueIfFound If <code>true</code> continue searching corresponding parents in the parent structure, otherwise only closest items are returned.
488     * @return parent containers of this program item.
489     */
490    public Set<Container> getParentContainers(ProgramItem programItem, boolean continueIfFound)
491    {
492        return _getParentsOfType(programItem, Container.class, continueIfFound);
493    }
494
495    /**
496     * Gets (recursively) parent programs of this course part.
497     * @param coursePart The course part
498     * @return parent programs of this course part.
499     */
500    public Set<Program> getParentPrograms(CoursePart coursePart)
501    {
502        Set<Program> programs = new HashSet<>();
503        for (Course course : coursePart.getCourses())
504        {
505            programs.addAll(getParentPrograms(course));
506        }
507        return programs;
508    }
509    
510    /**
511     * Gets (recursively) parent programs of this program item.
512     * @param programItem The program item
513     * @return parent programs of this program item.
514     */
515    public Set<Program> getParentPrograms(ProgramItem programItem)
516    {
517        return getParentPrograms(programItem, false);
518    }
519    
520    /**
521     * Gets (recursively) parent programs of this program item.
522     * @param programItem The program item
523     * @param includingItself <code>true</code> to return the program item if it's a {@link Program}.
524     * @return parent programs of this program item.
525     */
526    public Set<Program> getParentPrograms(ProgramItem programItem, boolean includingItself)
527    {
528        if (includingItself && programItem instanceof Program program)
529        {
530            return Set.of(program);
531        }
532        
533        return _getParentsOfType(programItem, Program.class, false);
534    }
535
536    /**
537     * Gets (recursively) parent subprograms of this course part.
538     * @param coursePart The course part
539     * @return parent subprograms of this course part.
540     */
541    public Set<SubProgram> getParentSubPrograms(CoursePart coursePart)
542    {
543        return getParentSubPrograms(coursePart, false);
544    }
545
546    /**
547     * Gets (recursively) parent subprograms of this course part.
548     * @param coursePart The course part
549     * @param continueIfFound If <code>true</code> continue searching corresponding parents in the parent structure, otherwise only closest items are returned.
550     * @return parent subprograms of this course part.
551     */
552    public Set<SubProgram> getParentSubPrograms(CoursePart coursePart, boolean continueIfFound)
553    {
554        Set<SubProgram> abstractPrograms = new HashSet<>();
555        for (Course course : coursePart.getCourses())
556        {
557            abstractPrograms.addAll(getParentSubPrograms(course, continueIfFound));
558        }
559        return abstractPrograms;
560    }
561    
562    /**
563     * Gets (recursively) parent subprograms of this program item.
564     * @param programItem The program item
565     * @return parent subprograms of this program item.
566     */
567    public Set<SubProgram> getParentSubPrograms(ProgramItem programItem)
568    {
569        return getParentSubPrograms(programItem, false);
570    }
571    
572    /**
573     * Gets (recursively) parent subprograms of this program item.
574     * @param programItem The program item
575     * @param continueIfFound If <code>true</code> continue searching corresponding parents in the parent structure, otherwise only closest items are returned.
576     * @return parent subprograms of this program item.
577     */
578    public Set<SubProgram> getParentSubPrograms(ProgramItem programItem, boolean continueIfFound)
579    {
580        return _getParentsOfType(programItem, SubProgram.class, continueIfFound);
581    }
582
583    /**
584     * Gets (recursively) parent abstract programs of this course part.
585     * @param coursePart The course part
586     * @return parent abstract programs of this course part.
587     */
588    public Set<AbstractProgram> getParentAbstractPrograms(CoursePart coursePart)
589    {
590        return getParentAbstractPrograms(coursePart, false);
591    }
592
593    /**
594     * Gets (recursively) parent abstract programs of this course part.
595     * @param coursePart The course part
596     * @param continueIfFound If <code>true</code> continue searching corresponding parents in the parent structure, otherwise only closest items are returned.
597     * @return parent abstract programs of this course part.
598     */
599    public Set<AbstractProgram> getParentAbstractPrograms(CoursePart coursePart, boolean continueIfFound)
600    {
601        Set<AbstractProgram> abstractPrograms = new HashSet<>();
602        for (Course course : coursePart.getCourses())
603        {
604            abstractPrograms.addAll(getParentAbstractPrograms(course, continueIfFound));
605        }
606        return abstractPrograms;
607    }
608    
609    /**
610     * Gets (recursively) parent abstract programs of this program item.
611     * @param programItem The program item
612     * @return parent abstract programs of this program item.
613     */
614    public Set<AbstractProgram> getParentAbstractPrograms(ProgramItem programItem)
615    {
616        return getParentAbstractPrograms(programItem, false);
617    }
618    
619    /**
620     * Gets (recursively) parent abstract programs of this program item.
621     * @param programItem The program item
622     * @param continueIfFound If <code>true</code> continue searching corresponding parents in the parent structure, otherwise only closest items are returned.
623     * @return parent abstract programs of this program item.
624     */
625    public Set<AbstractProgram> getParentAbstractPrograms(ProgramItem programItem, boolean continueIfFound)
626    {
627        return _getParentsOfType(programItem, AbstractProgram.class, continueIfFound);
628    }
629
630    private <T> Set<T> _getParentsOfType(ProgramItem programItem, Class<T> classToTest, boolean continueIfFound)
631    {
632        Set<ProgramItem> visitedProgramItems = new HashSet<>();
633        visitedProgramItems.add(programItem);
634        return _getParentsOfType(programItem, visitedProgramItems, classToTest, continueIfFound);
635    }
636    
637    @SuppressWarnings("unchecked")
638    private <T> Set<T> _getParentsOfType(ProgramItem programItem, Set<ProgramItem> visitedProgramItems, Class<T> classToTest, boolean continueIfFound)
639    {
640        Set<T> parentsOfType = new HashSet<>();
641        List<ProgramItem> parents = getParentProgramItems(programItem);
642        
643        for (ProgramItem parent : parents)
644        {
645            // Only parents not already visited
646            if (visitedProgramItems.add(parent))
647            {
648                // Cast to Content if instance of Content instead of another type (for structures containing both Container and SubProgram)
649                boolean found = false;
650                if (classToTest.isInstance(parent))
651                {
652                    parentsOfType.add((T) parent);
653                    found = true;
654                }
655                
656                if (!found || continueIfFound)
657                {
658                    parentsOfType.addAll(_getParentsOfType(parent, visitedProgramItems, classToTest, continueIfFound));
659                }
660            }
661        }
662        
663        return parentsOfType;
664    }
665    
666    /**
667     * Get the programs that belong to a given {@link OrgUnit} or a child orgunit
668     * @param orgUnit The orgUnit. Can be null
669     * @param catalog The catalog. Can be null.
670     * @param lang The lang. Can be null.
671     * @return The child programs as unmodifiable list
672     */
673    public List<Program> getProgramsFromOrgUnit(OrgUnit orgUnit, String catalog, String lang)
674    {
675        return getProgramsFromOrgUnit(orgUnit, catalog, lang, true);
676    }
677    
678    /**
679     * Get the child programs of an {@link OrgUnit}
680     * @param orgUnit The orgUnit. Can be null
681     * @param catalog The catalog. Can be null.
682     * @param lang The lang. Can be null.
683     * @param browseChildOrgunits Set to true to get programs among child orgunits recursively, false to get only programs directly linked to the given orgunit.
684     * @return The child programs as unmodifiable list
685     */
686    public List<Program> getProgramsFromOrgUnit(OrgUnit orgUnit, String catalog, String lang, boolean browseChildOrgunits)
687    {
688        Expression ouExpr = null;
689        if (orgUnit != null)
690        {
691            if (!browseChildOrgunits)
692            {
693                ouExpr = new StringExpression(ProgramItem.ORG_UNITS_REFERENCES, Operator.EQ, orgUnit.getId());
694            }
695            else
696            {
697                ouExpr = new OrExpression();
698                for (String orgUnitId : getSubOrgUnitIds(orgUnit))
699                {
700                    ((OrExpression) ouExpr).add(new StringExpression(ProgramItem.ORG_UNITS_REFERENCES, Operator.EQ, orgUnitId));
701                }
702            }
703        }
704        
705        AmetysObjectIterable<Program> programs = getProgramItems(ProgramFactory.PROGRAM_CONTENT_TYPE, null, catalog, lang, ouExpr, null);
706        return programs.stream().toList();
707    }
708    
709    /**
710     * Get the current orgunit and its suborgunits recursively identifiers.
711     * @param orgUnit The orgunit at the top
712     * @return A {@link List} of {@link OrgUnit} ids
713     */
714    public List<String> getSubOrgUnitIds(OrgUnit orgUnit)
715    {
716        List<String> orgUnitIds = new ArrayList<>();
717        orgUnitIds.add(orgUnit.getId());
718        for (String id : orgUnit.getSubOrgUnits())
719        {
720            OrgUnit childOrgUnit = _resolver.resolveById(id);
721            orgUnitIds.addAll(getSubOrgUnitIds(childOrgUnit));
722        }
723        
724        return orgUnitIds;
725    }
726    
727    /**
728     * Get all program item linked to the given orgUnit
729     * @param orgUnit the given orgUnit
730     * @return the set of program item
731     */
732    public Set<Program> getProgramsReferencingOrgunit(OrgUnit orgUnit)
733    {
734        StringExpression stringExpression = new StringExpression(ProgramItem.ORG_UNITS_REFERENCES, Operator.EQ, orgUnit.getId());
735        String contentXPathQuery = ContentQueryHelper.getContentXPathQuery(stringExpression);
736        
737        return _resolver.<Content>query(contentXPathQuery)
738            .stream()
739            .filter(ProgramItem.class::isInstance)
740            .map(ProgramItem.class::cast)
741            .map(p -> getParentPrograms(p, true))
742            .flatMap(Set::stream)
743            .collect(Collectors.toSet());
744    }
745    
746    /**
747     * Get the linked program items to the given person
748     * @param person the person
749     * @return the set of program items
750     */
751    public Set<Program> getProgramsReferencingPerson(Person person)
752    {
753        return _contentHelper.getReferencingContents(person)
754            .stream()
755            .map(Pair::getValue)
756            .map(this::_getProgramsReferencingContent)
757            .flatMap(Set::stream)
758            .collect(Collectors.toSet());
759    }
760    
761    private Set<Program> _getProgramsReferencingContent(Content content)
762    {
763        if (content instanceof ProgramItem programItem)
764        {
765            return getParentPrograms(programItem, true);
766        }
767        else if (content instanceof OrgUnit orgUnit)
768        {
769            return getProgramsReferencingOrgunit(orgUnit);
770        }
771        
772        return Set.of();
773    }
774    
775    /**
776     * Determines if the {@link ProgramItem} has parent program items
777     * @param programItem The program item
778     * @return true if has parent program items
779     */
780    public boolean hasParentProgramItems(ProgramItem programItem)
781    {
782        boolean hasParent = false;
783        
784        if (programItem instanceof ProgramPart programPart)
785        {
786            hasParent = !programPart.getProgramPartParents().isEmpty() || hasParent;
787        }
788        
789        if (programItem instanceof CourseList courseList)
790        {
791            hasParent = !courseList.getParentCourses().isEmpty() || hasParent;
792        }
793        
794        if (programItem instanceof Course course)
795        {
796            hasParent = !course.getParentCourseLists().isEmpty() || hasParent;
797        }
798        
799        return hasParent;
800    }
801    
802    /**
803     * Get the parent program items of a {@link ProgramItem}
804     * @param programItem The program item
805     * @return The parent program items
806     */
807    public List<ProgramItem> getParentProgramItems(ProgramItem programItem)
808    {
809        return getParentProgramItems(programItem, null);
810    }
811    
812    /**
813     * Get the program item parents into the given ancestor {@link ProgramPart}
814     * @param programItem The program item
815     * @param parentProgramPart The parent program, subprogram or container. If null, all parents program items will be returned.
816     * @return The parent program items which have given parent program part has an ancestor
817     */
818    public List<ProgramItem> getParentProgramItems(ProgramItem programItem, ProgramPart parentProgramPart)
819    {
820        Cache<ParentProgramItemsCacheKey, List<ProgramItem>> cache = _cacheManager.get(__PARENT_PROGRAM_ITEMS_CACHE);
821        
822        return cache.get(ParentProgramItemsCacheKey.of(programItem.getId(), parentProgramPart != null ? parentProgramPart.getId() : "__NOPARENT"), item -> {
823            List<ProgramItem> parents = new ArrayList<>();
824            
825            if (programItem instanceof Program)
826            {
827                return parents;
828            }
829            
830            if (programItem instanceof ProgramPart programPart)
831            {
832                List<ProgramPart> allParents = programPart.getProgramPartParents();
833                
834                for (ProgramPart parent : allParents)
835                {
836                    if (parentProgramPart == null || parent.equals(parentProgramPart))
837                    {
838                        parents.add(parent);
839                    }
840                    else if (!getParentProgramItems(parent, parentProgramPart).isEmpty())
841                    {
842                        parents.add(parent);
843                    }
844                }
845            }
846            
847            if (programItem instanceof CourseList courseList)
848            {
849                for (Course parentCourse : courseList.getParentCourses())
850                {
851                    if (!getParentProgramItems(parentCourse, parentProgramPart).isEmpty())
852                    {
853                        parents.add(parentCourse);
854                    }
855                }
856            }
857            
858            if (programItem instanceof Course course)
859            {
860                for (CourseList cl : course.getParentCourseLists())
861                {
862                    if (!getParentProgramItems(cl, parentProgramPart).isEmpty())
863                    {
864                        parents.add(cl);
865                    }
866                    
867                }
868            }
869            
870            return parents;
871        });
872    }
873    
874    private static class ParentProgramItemsCacheKey extends AbstractCacheKey
875    {
876        public ParentProgramItemsCacheKey(String programItemId, String parentProgramPartId)
877        {
878            super(programItemId, parentProgramPartId);
879        }
880        
881        public static ParentProgramItemsCacheKey of(String programItemId, String parentProgramPartId)
882        {
883            return new ParentProgramItemsCacheKey(programItemId, parentProgramPartId);
884        }
885    }
886    
887    /**
888     * Get the first nearest program item parent into the given parent {@link AbstractProgram}
889     * @param programItem The program item
890     * @param parentProgram The parent program or subprogram. If null, the nearest abstract program will be returned.
891     * @return The parent program item or null if not found.
892     */
893    public ProgramItem getParentProgramItem(ProgramItem programItem, AbstractProgram parentProgram)
894    {
895        List<ProgramItem> parentProgramItems = getParentProgramItems(programItem, parentProgram);
896        return parentProgramItems.isEmpty() ? null : parentProgramItems.get(0);
897    }
898    
899    /**
900     * Get information of the program item
901     * @param programItemId the program item id
902     * @param programItemPathIds the list of program item ids containing in the path of the program item ... starting with itself. Can be null or empty
903     * @return a map of information
904     */
905    @Callable (rights = Callable.READ_ACCESS, paramIndex = 0, rightContext = ContentRightAssignmentContext.ID)
906    public Map<String, Object> getProgramItemInfo(String programItemId, List<String> programItemPathIds)
907    {
908        Map<String, Object> results = new HashMap<>();
909        ProgramItem programItem = _resolver.resolveById(programItemId);
910
911        // Get catalog
912        String catalog = programItem.getCatalog();
913        if (StringUtils.isNotBlank(catalog))
914        {
915            results.put("catalog", catalog);
916        }
917        
918        // Get the orgunits
919        List<String> orgUnits = programItem.getOrgUnits();
920        if (programItemPathIds == null || programItemPathIds.isEmpty())
921        {
922            // The programItemPathIds is null or empty because we do not know the program item context.
923            // so get the information in the parent structure if unique.
924            while (programItem != null && orgUnits.isEmpty())
925            {
926                orgUnits = programItem.getOrgUnits();
927                List<ProgramItem> parentProgramItems = getParentProgramItems(programItem);
928                programItem = parentProgramItems.size() == 1 ? parentProgramItems.get(0) : null;
929            }
930        }
931        else // We have the program item context: parent structure is known ...
932        {
933            // ... the first element of the programItemPathIds is the programItem itself, so begin to index 1
934            int position = 1;
935            int size = programItemPathIds.size();
936            while (position < size && orgUnits.isEmpty())
937            {
938                programItem = _resolver.resolveById(programItemPathIds.get(position));
939                orgUnits = programItem.getOrgUnits();
940                position++;
941            }
942        }
943        results.put("orgUnits", orgUnits);
944        
945        return results;
946    }
947    
948    /**
949     * Get information of the program item structure (type, if program has children) or orgunit (no structure for now)
950     * @param contentId the content id
951     * @return a map of information
952     */
953    @Callable (rights = Callable.READ_ACCESS, paramIndex = 0, rightContext = ContentRightAssignmentContext.ID)
954    public Map<String, Object> getStructureInfo(String contentId)
955    {
956        if (StringUtils.isNotBlank(contentId))
957        {
958            Content content = _resolver.resolveById(contentId);
959            return getStructureInfo(content);
960        }
961        
962        return Map.of();
963    }
964    
965    /**
966     * Get information of the program item structure (type, if program has children) or orgunit (no structure for now)
967     * @param content the content
968     * @return a map of information
969     */
970    public Map<String, Object> getStructureInfo(Content content)
971    {
972        Map<String, Object> results = new HashMap<>();
973        
974        if (content instanceof ProgramItem programItem)
975        {
976            results.put("id", content.getId());
977            results.put("title", content.getTitle());
978            results.put("code", programItem.getDisplayCode());
979            
980            List<ProgramItem> childProgramItems = getChildProgramItems(programItem);
981            results.put("hasChildren", !childProgramItems.isEmpty());
982            
983            List<ProgramItem> parentProgramItems = getParentProgramItems(programItem);
984            results.put("hasParent", !parentProgramItems.isEmpty());
985            
986            results.put("paths", getPaths(programItem, " > "));
987        }
988        else if (content instanceof OrgUnit orgunit)
989        {
990            results.put("id", content.getId());
991            results.put("title", content.getTitle());
992            results.put("code", orgunit.getUAICode());
993            
994            // Always to false, we don't manage complete copy with children
995            results.put("hasChildren", false);
996            
997            results.put("hasParent", orgunit.getParentOrgUnit() != null);
998            
999            results.put("paths", List.of(getOrgUnitPath(orgunit, " > ")));
1000        }
1001        
1002        return results;
1003    }
1004    
1005    /**
1006     * Get information of the program item structure (type, if program has children)
1007     * @param programItemIds the list of program item id
1008     * @return a map of information
1009     */
1010    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
1011    public Map<String, Map<String, Object>> getStructureInfo(List<String> programItemIds)
1012    {
1013        Map<String,  Map<String, Object>> results = new HashMap<>();
1014        
1015        for (String programItemId : programItemIds)
1016        {
1017            Content content = _resolver.resolveById(programItemId);
1018            if (_rightManager.currentUserHasReadAccess(content))
1019            {
1020                results.put(programItemId, getStructureInfo(content));
1021            }
1022        }
1023        
1024        return results;
1025    }
1026    
1027    /**
1028     * Get all the path of the orgunit.<br>
1029     * The path is built with the contents' title and code
1030     * @param orgunit The orgunit
1031     * @param separator The path separator
1032     * @return the path in parent orgunit
1033     */
1034    public String getOrgUnitPath(OrgUnit orgunit, String separator)
1035    {
1036        String path = orgunit.getTitle() + " (" + orgunit.getUAICode() + ")";
1037        OrgUnit parent = orgunit.getParentOrgUnit();
1038        if (parent != null)
1039        {
1040            path = getOrgUnitPath(parent, separator) + separator + path;
1041        }
1042        return path;
1043    }
1044    
1045    /**
1046     * Get all {@link EducationalPath} of a {@link ProgramItem} as readable values
1047     * The path is built with the contents' title and code
1048     * @param item The program item
1049     * @param separator The path separator
1050     * @return the paths in parent program items
1051     */
1052    public List<String> getPaths(ProgramItem item, String separator)
1053    {
1054        Function<ProgramItem, String> mapper = c -> ((Content) c).getTitle() + " (" + c.getDisplayCode() + ")";
1055        return getPaths(item, separator, mapper, true);
1056    }
1057    
1058    /**
1059     * Get all {@link EducationalPath} of a {@link ProgramItem} as readable values
1060     * The path is built with the mapper function.
1061     * @param item The program item
1062     * @param separator The path separator
1063     * @param mapper the function to apply to each program item to build the path
1064     * @param includeItself set to false to not include final item in path
1065     * @return the paths in parent program items
1066     */
1067    public List<String> getPaths(ProgramItem item, String separator, Function<ProgramItem, String> mapper, boolean includeItself)
1068    {
1069        return getPaths(item, separator, mapper, x -> true, includeItself, false);
1070    }
1071    
1072    /**
1073     * Get all {@link EducationalPath} of a {@link ProgramItem} as readable values
1074     * The path is built with the mapper function.
1075     * @param item The program item
1076     * @param separator The path separator
1077     * @param mapper the function to apply to each program item to build the path
1078     * @param filterPathSegment predicate to exclude some program item of path
1079     * @param includeItself set to false to not include final item in path
1080     * @param ignoreOrphanPath set to true to ignore paths that is not part of a Program
1081     * @return the paths in parent program items
1082     */
1083    public List<String> getPaths(ProgramItem item, String separator, Function<ProgramItem, String> mapper, Predicate<ProgramItem> filterPathSegment, boolean includeItself, boolean ignoreOrphanPath)
1084    {
1085        List<EducationalPath> educationalPaths = getEducationalPaths(item, includeItself, ignoreOrphanPath);
1086        
1087        return educationalPaths.stream()
1088            .map(p -> getEducationalPathAsString(p, mapper, separator, filterPathSegment))
1089            .collect(Collectors.toList());
1090    }
1091    
1092    /**
1093     * Get the value of an attribute for a given educational path. The attribute is necessarily in a repeater composed of at least one educational path attribute and the attribute to be retrieved.
1094     * @param <T> The type of the value returned by the path
1095     * @param programItem The program item
1096     * @param path Full or partial educational path. Cannot be null. In case of partial educational path (ie., no root program) the full possible paths will be computed.
1097     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1098     * @return the value for this educational path
1099     */
1100    public <T> Optional<T> getValueForPath(ProgramItem programItem, String dataPath, EducationalPath path)
1101    {
1102        return getValueForPath(programItem, dataPath, List.of(path));
1103    }
1104    
1105    
1106    /**
1107     * Get the value of an attribute for a given educational path. The attribute is necessarily in a repeater composed of at least one educational path attribute and the attribute to be retrieved.
1108     * @param <T> The type of the value returned by the path
1109     * @param programItem The program item
1110     * @param paths Full educational paths (from root program). Cannot be null nor empty.
1111     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1112     * @return the value for this educational path
1113     */
1114    public <T> Optional<T> getValueForPath(ProgramItem programItem, String dataPath, List<EducationalPath> paths)
1115    {
1116        String repeaterPath = StringUtils.substringBeforeLast(dataPath, "/");
1117        String attributeName = StringUtils.substringAfterLast(dataPath, "/");
1118        
1119        Content content = (Content) programItem;
1120        if (!content.hasValue(repeaterPath))
1121        {
1122            // Repeater is not defined
1123            getLogger().warn("There is no repeater '{}' defined for content '{}'", repeaterPath, programItem.getId());
1124            return Optional.empty();
1125        }
1126        
1127        // Get the repeater
1128        ModelAwareRepeater repeater = content.getRepeater(repeaterPath);
1129        
1130        // Get a value for each value in paths because if it is multiple, we want to check all paths, keep null values
1131        List<T> values = paths.stream()
1132             .map(path ->
1133                 getRepeaterEntriesByPath(repeater, List.of(path))
1134                     .findFirst()
1135                     .map(e -> e.<T>getValue(attributeName))
1136                     .orElse(null)
1137             )
1138             .toList();
1139        
1140        boolean isSameValues = values.stream().distinct().count() == 1;
1141        if (!isSameValues)
1142        {
1143            // No same values for each path
1144            getLogger().warn("Unable to determine value for '{}' attribute of content '{}'. Multiple educational paths are available for requested context with no same values", dataPath, programItem.getId());
1145            return Optional.empty();
1146        }
1147
1148        // Same value for each available paths => return this common value
1149        return values.stream().filter(Objects::nonNull).findFirst();
1150    }
1151    
1152    /**
1153     * Get the value of an attribute for each available educational paths
1154     * @param <T> The type of the values returned by the path
1155     * @param programItem The program item
1156     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1157     * @param defaultValue The default value to use if the repeater contains no entry for this educational path. Can be null.
1158     * @return the values for each educational paths
1159     */
1160    public <T> Map<EducationalPath, T> getValuesForPaths(ProgramItem programItem, String dataPath, T defaultValue)
1161    {
1162        return getValuesForPaths(programItem, dataPath, getEducationalPaths(programItem), defaultValue);
1163    }
1164    
1165    /**
1166     * Get the value of an attribute for each given educational paths
1167     * @param <T> The type of the value returned by the path
1168     * @param programItem The program item
1169     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1170     * @param paths The full educational paths (from root programs)
1171     * @param defaultValue The default value to use if the repeater contains no entry for this educational path. Can be null.
1172     * @return the values for each educational paths
1173     */
1174    @SuppressWarnings("unchecked")
1175    public <T> Map<EducationalPath, T> getValuesForPaths(ProgramItem programItem, String dataPath, List<EducationalPath> paths, T defaultValue)
1176    {
1177        Map<EducationalPath, T> valuesByPath = new HashMap<>();
1178        
1179        paths.stream().forEach(path -> {
1180            valuesByPath.put(path, (T) getValueForPath(programItem, dataPath, path).orElse(defaultValue));
1181        });
1182        
1183        return valuesByPath;
1184    }
1185    
1186    /**
1187     * Determines if the values of an attribute depending of a educational path is the same for all available educational paths
1188     * @param programItem The program item
1189     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1190     * @return true if the value is the same for all available educational paths
1191     */
1192    public boolean isSameValueForAllPaths(ProgramItem programItem, String dataPath)
1193    {
1194        return isSameValueForPaths(programItem, dataPath, getEducationalPaths(programItem));
1195    }
1196    
1197    /**
1198     * Determines if the values of an attribute depending of a educational path is the same for all available educational paths
1199     * @param programItem The program item
1200     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1201     * @param paths The full educational paths (from root programs)
1202     * @return true if the value is the same for all available educational paths
1203     */
1204    public boolean isSameValueForPaths(ProgramItem programItem, String dataPath, List<EducationalPath> paths)
1205    {
1206        String repeaterPath = StringUtils.substringBeforeLast(dataPath, "/");
1207        if (!((Content) programItem).hasValue(repeaterPath))
1208        {
1209            // Repeater is empty, the value is the default value for all educational paths
1210            return true;
1211        }
1212        
1213        return getValuesForPaths(programItem, dataPath, paths, null).values().stream().distinct().count() == 1;
1214    }
1215    
1216    /**
1217     * Get a position of repeater entry that match the given educational path
1218     * @param programItem The program item
1219     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1220     * @param path The full educational path (from root program). Cannot be null.
1221     * @return the index position of the entry matching the educational path with a non-empty value for requested attribute, -1 otherwise
1222     */
1223    public int getRepeaterEntryPositionForPath(ProgramItem programItem, String dataPath, EducationalPath path)
1224    {
1225        return getRepeaterEntryPositionForPath(programItem, dataPath, List.of(path));
1226    }
1227    
1228    /**
1229     * Get a position of repeater entry that match the given educational path
1230     * @param programItem The program item
1231     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1232     * @param paths The full educational paths (from root program). Cannot be null.
1233     * @return the index position of the entry matching the educational path with a non-empty value for requested attribute, -1 otherwise
1234     */
1235    public int getRepeaterEntryPositionForPath(ProgramItem programItem, String dataPath, List<EducationalPath> paths)
1236    {
1237        String repeaterPath = StringUtils.substringBeforeLast(dataPath, "/");
1238        String attributeName = StringUtils.substringAfterLast(dataPath, "/");
1239        
1240        Content content = (Content) programItem;
1241        if (!content.hasValue(repeaterPath))
1242        {
1243            // Repeater is not defined
1244            getLogger().debug("There is no repeater '{}' defined for content '{}'", repeaterPath, programItem.getId());
1245            return -1;
1246        }
1247        
1248        // Get the repeater
1249        ModelAwareRepeater repeater = content.getRepeater(repeaterPath);
1250        
1251        // Get a value for each value in paths because if it is multiple, we want to check all paths
1252        List<Pair<Integer, Object>> values = paths.stream()
1253             .map(path ->
1254                 getRepeaterEntriesByPath(repeater, List.of(path))
1255                     .findFirst()
1256                     .map(e -> Pair.of(e.getPosition(), e.getValue(attributeName)))
1257                     .orElseGet(() -> Pair.of(-1, null))
1258             )
1259             .toList();
1260        
1261        boolean isSameValues = values.stream().map(Pair::getRight).distinct().count() == 1;
1262        if (!isSameValues)
1263        {
1264            // No same values for each path
1265            getLogger().warn("Unable to determine repeater entry for '{}' attribute of content '{}'. Multiple educational paths are available for requested context with no same values", dataPath, programItem.getId());
1266            return -1;
1267        }
1268        
1269        // Same value for each available paths => return position of any entry
1270        return values.stream().findFirst().map(Pair::getLeft).orElse(-1);
1271    }
1272    
1273    /**
1274     * Get the full educations paths (from a root {@link Program}) from a full or partial path
1275     * @param path A full or partial path composed by program item ancestors
1276     * @return the full educational paths
1277     */
1278    public List<EducationalPath> getEducationPathFromPath(EducationalPath path)
1279    {
1280        return getEducationPathFromPath(path.getProgramItems(_resolver));
1281    }
1282    
1283    /**
1284     * Get the full educations paths (from a root {@link Program}) from a full or partial path
1285     * @param path A full or partial path composed by program item ancestors
1286     * @return the full educational paths
1287     */
1288    public List<EducationalPath> getEducationPathFromPath(List<ProgramItem> path)
1289    {
1290        return getEducationPathFromPaths(List.of(path));
1291    }
1292    
1293    /**
1294     * Get the full educations paths (from a root {@link Program}) from full or partial paths
1295     * @param paths full or partial paths composed by program item ancestors
1296     * @return the full educational paths
1297     */
1298    public List<EducationalPath> getEducationPathFromPaths(List<List<ProgramItem>> paths)
1299    {
1300        return getEducationPathFromPaths(paths, null);
1301    }
1302    
1303    /**
1304     * Get the full educations paths (from a root {@link Program})from full or partial paths
1305     * @param paths full or partial paths composed by program item ancestors
1306     * @param withAncestor filter the educational paths that contains this ancestor. Can be null.
1307     * @return the full educational paths
1308     */
1309    public List<EducationalPath> getEducationPathFromPaths(List<List<ProgramItem>> paths, ProgramItem withAncestor)
1310    {
1311        List<EducationalPath> fullPaths = new ArrayList<>();
1312        
1313        for (List<ProgramItem> partialPath : paths)
1314        {
1315            ProgramItem firstProgramItem = partialPath.get(0);
1316            if (!(firstProgramItem instanceof Program))
1317            {
1318                // First program item of path is not a root program => computed the available full paths from this first item ancestors
1319                List<EducationalPath> parentEducationalPaths = getEducationalPaths(firstProgramItem, false);
1320                
1321                fullPaths.addAll(parentEducationalPaths.stream()
1322                    .filter(p -> withAncestor == null || p.getProgramItems(_resolver).contains(withAncestor)) // filter educational paths that is not composed by the required ancestor
1323                    .map(p -> EducationalPath.of(p, partialPath.toArray(ProgramItem[]::new))) // concat path
1324                    .toList());
1325            }
1326            else if (withAncestor == null || partialPath.contains(withAncestor))
1327            {
1328                // The path is already a full path
1329                fullPaths.add(EducationalPath.of(partialPath.toArray(ProgramItem[]::new)));
1330            }
1331        }
1332        
1333        return fullPaths;
1334    }
1335    
1336    /**
1337     * Get all {@link EducationalPath} of a {@link ProgramItem}
1338     * The path is built with the mapper function.
1339     * @param programItem The program item
1340     * @return the paths in parent program items
1341     */
1342    public List<EducationalPath> getEducationalPaths(ProgramItem programItem)
1343    {
1344        return getEducationalPaths(programItem, true);
1345    }
1346    
1347    /**
1348     * Get all {@link EducationalPath} of a {@link ProgramItem}
1349     * The path is built with the mapper function.
1350     * @param programItem The program item
1351     * @param includeItself set to false to not include final item in path
1352     * @return the paths in parent program items
1353     */
1354    public List<EducationalPath> getEducationalPaths(ProgramItem programItem, boolean includeItself)
1355    {
1356        return getEducationalPaths(programItem, includeItself, false);
1357    }
1358    
1359    /**
1360     * Get all {@link EducationalPath} of a {@link ProgramItem}
1361     * The path is built with the mapper function.
1362     * @param programItem The program item
1363     * @param includeItself set to false to not include final item in path
1364     * @param ignoreOrphanPath set to true to ignore paths that is not part of a Program
1365     * @return the paths in parent program items
1366     */
1367    public List<EducationalPath> getEducationalPaths(ProgramItem programItem, boolean includeItself, boolean ignoreOrphanPath)
1368    {
1369        List<EducationalPath> paths = new ArrayList<>();
1370        
1371        List<List<ProgramItem>> ancestorPaths = getPathOfAncestors(programItem);
1372        for (List<ProgramItem> ancestorPath : ancestorPaths)
1373        {
1374            if (!ignoreOrphanPath || ancestorPath.get(0) instanceof Program) // ignore paths that is not part of a Program if ignoreOrphanPath is true
1375            {
1376                // Don't modify directly the returned value, it may be immutable and cause side effects
1377                List<ProgramItem> ancestorPathCopy = new ArrayList<>(ancestorPath);
1378                if (!includeItself)
1379                {
1380                    ancestorPathCopy.remove(programItem);
1381                }
1382                
1383                if (!ancestorPathCopy.isEmpty())
1384                {
1385                    paths.add(EducationalPath.of(ancestorPathCopy.toArray(ProgramItem[]::new)));
1386                }
1387            }
1388        }
1389        
1390        return paths;
1391    }
1392    
1393    /**
1394     * Get a readable value of a {@link EducationalPath}
1395     * @param path the educational path
1396     * @return a String representing the path with program item's title separated by '>'
1397     */
1398    public String getEducationalPathAsString(EducationalPath path)
1399    {
1400        return getEducationalPathAsString(path, pi -> ((Content) pi).getTitle(), " > ");
1401    }
1402    
1403    /**
1404     * Get a readable value of a {@link EducationalPath}
1405     * @param path the educational path
1406     * @param mapper the function to use for the readable value of a program item
1407     * @param separator the separator to use
1408     * @return a String representing the path with program item's readable value given by mapper function and separated by given separator
1409     */
1410    public String getEducationalPathAsString(EducationalPath path, Function<ProgramItem, String> mapper, CharSequence separator)
1411    {
1412        return getEducationalPathAsString(path, mapper, separator, x -> true);
1413    }
1414    
1415    /**
1416     * Get a readable value of a {@link EducationalPath}
1417     * @param path the educational path
1418     * @param mapper the function to use for the readable value of a program item
1419     * @param separator the separator to use
1420     * @param filterPathSegment predicate to exclude some program item of path
1421     * @return a String representing the path with program item's readable value given by mapper function and separated by given separator
1422     */
1423    public String getEducationalPathAsString(EducationalPath path, Function<ProgramItem, String> mapper, CharSequence separator, Predicate<ProgramItem> filterPathSegment)
1424    {
1425        return path.resolveProgramItems(_resolver)
1426                .filter(filterPathSegment)
1427                .map(mapper)
1428                .collect(Collectors.joining(separator));
1429    }
1430    
1431    /**
1432     * Determines if a educational path is valid ODF path
1433     * @param path the educational path
1434     * @return <code>true</code> if path represents a valid ODF path
1435     */
1436    public boolean isValid(EducationalPath path)
1437    {
1438        // Get leaf program item of this educational path
1439        String leafProgramItemId = path.getProgramItemIds().getLast();
1440        if (_resolver.hasAmetysObjectForId(leafProgramItemId))
1441        {
1442            ProgramItem leafProgramItem = _resolver.resolveById(leafProgramItemId);
1443            return isValid(path, leafProgramItem, false);
1444        }
1445        return false;
1446    }
1447    
1448    
1449    /**
1450     * Determines if the given educational path is a valid path for the program item
1451     * @param path the educational path. The path must include the program item itself
1452     * @param programItem the program item
1453     * @param ignoreOrphanPath set to true to ignore paths that is not part of a Program
1454     * @return <code>true</code> if path is valid for the given program item
1455     */
1456    public boolean isValid(EducationalPath path, ProgramItem programItem, boolean ignoreOrphanPath)
1457    {
1458        return getEducationalPaths(programItem, true, ignoreOrphanPath).contains(path);
1459    }
1460    
1461    /**
1462     * Get the full path to program item for highest ancestors. The path includes this final item.
1463     * @param programItem the program item
1464     * @return a list for each highest ancestors found. Each item of the list contains the program items to the path to this program item.
1465     */
1466    protected List<List<ProgramItem>> getPathOfAncestors(ProgramItem programItem)
1467    {
1468        Cache<ProgramItem, List<List<ProgramItem>>> cache = _cacheManager.get(__ANCESTORS_CACHE);
1469        
1470        return cache.get(programItem, item -> {
1471            List<ProgramItem> parentProgramItems = getParentProgramItems(item);
1472            
1473            // There is no more parents, the only path is the item itself
1474            if (parentProgramItems.isEmpty())
1475            {
1476                return List.of(List.of(item));
1477            }
1478            
1479            List<List<ProgramItem>> ancestors = new ArrayList<>();
1480            
1481            // Compute the path for each parent
1482            for (ProgramItem parentProgramItem : parentProgramItems)
1483            {
1484                for (List<ProgramItem> ancestorPaths : getPathOfAncestors(parentProgramItem))
1485                {
1486                    List<ProgramItem> ancestorPathsCopy = new ArrayList<>(ancestorPaths);
1487                    ancestorPathsCopy.add(item);
1488                    // Add an immutable list to avoid unvoluntary modifications in cache
1489                    ancestors.add(Collections.unmodifiableList(ancestorPathsCopy));
1490                }
1491            }
1492
1493            // Add an immutable list to avoid unvoluntary modifications in cache
1494            return Collections.unmodifiableList(ancestors);
1495        });
1496    }
1497    
1498    /**
1499     * Get the enumeration of educational paths for a program item for highest ancestors. The paths does not includes this final item.
1500     * @param programItemId the id of program item
1501     * @return a list of educational paths with paths' label (composed by ancestors' title separated by '>') and paths' id (composed by ancestors' id seperated by coma)
1502     */
1503    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1504    public List<Map<String, String>> getEducationalPathsEnumeration(String programItemId)
1505    {
1506        List<Map<String, String>> paths = new ArrayList<>();
1507        
1508        ProgramItem programItem = _resolver.resolveById(programItemId);
1509        
1510        getEducationalPaths(programItem)
1511            .stream()
1512            .forEach(p -> paths.add(Map.of("id", p.toString(), "title", getEducationalPathAsString(p, c -> ((Content) c).getTitle(), " > "))));
1513        
1514        return paths;
1515    }
1516    
1517    /**
1518     * Get the parent program enumeration for a program item
1519     * @param programItemId The id of the program item
1520     * @return The enumeration of parent programs
1521     */
1522    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1523    public List<Map<String, Object>> getParentProgramEnumeration(String programItemId)
1524    {
1525        try
1526        {
1527            Content content = _resolver.resolveById(programItemId);
1528            if (content instanceof ProgramItem programItem)
1529            {
1530                List<Map<String, Object>> enumeration = new ArrayList<>();
1531                
1532                getParentPrograms(programItem)
1533                        .stream()
1534                        .forEach(p -> enumeration.add(Map.of("id", p.getId(), "title", p.getTitle())));
1535                
1536                return enumeration;
1537            }
1538            else
1539            {
1540                throw new IllegalArgumentException("Tried to retreieve the parent programs enumeration of the content '" + content.getTitle() + "' (" + content.getId() + ") that is not a program item.");
1541            }
1542        }
1543        catch (AmetysRepositoryException e)
1544        {
1545            throw new IllegalArgumentException("Tried to retreieve the parent program enumeration on an unexisting content", e);
1546        }
1547    }
1548    
1549    /**
1550     * Get the path of a {@link ProgramItem} into a {@link Program}<br>
1551     * The path is construct with the contents' names and the used separator is '/'.
1552     * @param programItemId The id of the program item
1553     * @param programId The id of program. Can not be null.
1554     * @return the path into the parent program or null if the item is not part of this program.
1555     */
1556    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1557    public String getPathInProgram (String programItemId, String programId)
1558    {
1559        ProgramItem item = _resolver.resolveById(programItemId);
1560        Program program = _resolver.resolveById(programId);
1561        
1562        return getPathInProgram(item, program);
1563    }
1564    
1565    /**
1566     * Get the path of a ODF content into a {@link Program}.<br>
1567     * The path is construct with the contents' names and the used separator is '/'.
1568     * @param item The program item
1569     * @param parentProgram The parent root (sub)program. Can not be null.
1570     * @return the path from the parent program
1571     */
1572    public String getPathInProgram (ProgramItem item, Program parentProgram)
1573    {
1574        if (item instanceof Program)
1575        {
1576            // The program item is already the program it self or another program
1577            return item.equals(parentProgram) ? "" : null;
1578        }
1579        
1580        List<EducationalPath> paths = getEducationalPaths(item, true, true);
1581        
1582        for (EducationalPath path : paths)
1583        {
1584            if (path.getProgramItemIds().contains(parentProgram.getId()))
1585            {
1586                // Find a path that match the given parent program
1587                Stream<ProgramItem> resolvedPath = path.resolveProgramItems(_resolver);
1588                return resolvedPath.map(ProgramItem::getName).collect(Collectors.joining("/"));
1589            }
1590        }
1591        
1592        return null;
1593    }
1594    
1595    /**
1596     * Get the path of a {@link Course} or a {@link CourseList} into a {@link Course}<br>
1597     * The path is construct with the contents' names and the used separator is '/'.
1598     * @param contentId The id of the content
1599     * @param parentCourseId The id of parent course. Can not be null.
1600     * @return the path into the parent course or null if the item is not part of this course.
1601     */
1602    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1603    public String getPathInCourse (String contentId, String parentCourseId)
1604    {
1605        Content content = _resolver.resolveById(contentId);
1606        Course parentCourse = _resolver.resolveById(parentCourseId);
1607        
1608        return getPathInCourse(content, parentCourse);
1609    }
1610    
1611    /**
1612     * Get the path of a {@link Course}  or a {@link CourseList} into a {@link Course}<br>
1613     * The path is construct with the contents' names and the used separator is '/'.
1614     * @param courseOrList The course or the course list
1615     * @param parentCourse The parent course. Can not be null.
1616     * @return the path into the parent course or null if the item is not part of this course.
1617     */
1618    public String getPathInCourse(Content courseOrList, Course parentCourse)
1619    {
1620        if (courseOrList.equals(parentCourse))
1621        {
1622            return "";
1623        }
1624        
1625        String path = _getPathInCourse(courseOrList, parentCourse);
1626        
1627        return path;
1628    }
1629    
1630    private String _getPathInCourse(Content content, Content parentContent)
1631    {
1632        if (content.equals(parentContent))
1633        {
1634            return content.getName();
1635        }
1636
1637        List<? extends Content> parents;
1638        
1639        if (content instanceof Course course)
1640        {
1641            parents = course.getParentCourseLists();
1642        }
1643        else if (content instanceof CourseList courseList)
1644        {
1645            parents = courseList.getParentCourses();
1646        }
1647        else
1648        {
1649            throw new IllegalStateException();
1650        }
1651        
1652        for (Content parent : parents)
1653        {
1654            String path = _getPathInCourse(parent, parentContent);
1655            if (path != null)
1656            {
1657                return path + '/' + content.getName();
1658            }
1659        }
1660        return null;
1661    }
1662    
1663    /**
1664     * Get the hierarchical path of a {@link OrgUnit} from the root orgunit id.<br>
1665     * The path is construct with the contents' names and the used separator is '/'.
1666     * @param orgUnitId The id of the orgunit
1667     * @param rootOrgUnitId The root orgunit id
1668     * @return the path into the parent program or null if the item is not part of this program.
1669     */
1670    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1671    public String getOrgUnitPath(String orgUnitId, String rootOrgUnitId)
1672    {
1673        OrgUnit rootOU = null;
1674        if (StringUtils.isNotBlank(rootOrgUnitId))
1675        {
1676            rootOU = _resolver.resolveById(rootOrgUnitId);
1677        }
1678        else
1679        {
1680            rootOU = _ouRootProvider.getRoot();
1681        }
1682        
1683        if (orgUnitId.equals(rootOU.getId()))
1684        {
1685            // The orgunit is already the root orgunit
1686            return rootOU.getName();
1687        }
1688        
1689        OrgUnit ou = _resolver.resolveById(orgUnitId);
1690        
1691        List<String> paths = new ArrayList<>();
1692        paths.add(ou.getName());
1693        
1694        OrgUnit parent = ou.getParentOrgUnit();
1695        while (parent != null && !parent.getId().equals(rootOU.getId()))
1696        {
1697            paths.add(parent.getName());
1698            parent = parent.getParentOrgUnit();
1699        }
1700        
1701        if (parent != null)
1702        {
1703            paths.add(rootOU.getName());
1704            Collections.reverse(paths);
1705            return StringUtils.join(paths, "/");
1706        }
1707        
1708        return null;
1709    }
1710    
1711    /**
1712     * Get the hierarchical path of a {@link OrgUnit} from the root orgunit.<br>
1713     * The path is construct with the contents' names and the used separator is '/'.
1714     * @param orgUnitId The id of the orgunit
1715     * @return the path into the parent program or null if the item is not part of this program.
1716     */
1717    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1718    public String getOrgUnitPath(String orgUnitId)
1719    {
1720        return getOrgUnitPath(orgUnitId, null);
1721    }
1722    
1723    /**
1724     * Return true if the given {@link ProgramPart} has in its hierarchy a parent of given id
1725     * @param part The program part
1726     * @param parentId The ancestor id
1727     * @return true if the given {@link ProgramPart} has in its hierarchy a parent of given id
1728     */
1729    public boolean hasAncestor (ProgramPart part, String parentId)
1730    {
1731        List<ProgramPart> parents = part.getProgramPartParents();
1732        
1733        for (ProgramPart parent : parents)
1734        {
1735            if (parent.getId().equals(parentId))
1736            {
1737                return true;
1738            }
1739            else if (hasAncestor(parent, parentId))
1740            {
1741                return true;
1742            }
1743        }
1744        
1745        return false;
1746    }
1747    
1748    /**
1749     * Determines if a program item is shared
1750     * @param programItem the program item
1751     * @return true if the program item is shared
1752     */
1753    public boolean isShared(ProgramItem programItem)
1754    {
1755        List<ProgramItem> parents = getParentProgramItems(programItem);
1756        if (parents.size() > 1)
1757        {
1758            return true;
1759        }
1760        else
1761        {
1762            return parents.isEmpty() ? false : isShared(parents.get(0));
1763        }
1764    }
1765    
1766    /**
1767     * Check if a relation can be establish between two ODF contents
1768     * @param srcContent The source content (copied or moved)
1769     * @param targetContent The target content
1770     * @param errors The list of error messages
1771     * @param contextualParameters the contextual parameters
1772     * @return true if the relation is valid, false otherwise
1773     */
1774    public boolean isRelationCompatible(Content srcContent, Content targetContent, List<I18nizableText> errors, Map<String, Object> contextualParameters)
1775    {
1776        boolean isCompatible = true;
1777        
1778        if (targetContent instanceof ProgramItem || targetContent instanceof OrgUnit)
1779        {
1780            if (!_isContentTypeCompatible(srcContent, targetContent))
1781            {
1782                // Invalid relations between content types
1783                errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_CONTENT_TYPES", _getContentParameters(srcContent, targetContent)));
1784                isCompatible = false;
1785            }
1786            else if (!_isCatalogCompatible(srcContent, targetContent))
1787            {
1788                // Catalog is invalid
1789                errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_CATALOG", _getContentParameters(srcContent, targetContent)));
1790                isCompatible = false;
1791            }
1792            else if (!_isLanguageCompatible(srcContent, targetContent))
1793            {
1794                // Language is invalid
1795                errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_LANGUAGE", _getContentParameters(srcContent, targetContent)));
1796                isCompatible = false;
1797            }
1798            else if (!_areShareableFieldsCompatibles(srcContent, targetContent, contextualParameters))
1799            {
1800                // Shareable fields don't match
1801                errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_SHAREABLE_COURSE", _getContentParameters(srcContent, targetContent)));
1802                isCompatible = false;
1803            }
1804        }
1805        else if (srcContent instanceof ProgramItem || srcContent instanceof OrgUnit)
1806        {
1807            // If the target isn't ODF related but the source is, the relation is not compatible.
1808            errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_NO_PROGRAM_ITEM", _getContentParameters(srcContent, targetContent)));
1809            isCompatible = false;
1810        }
1811        
1812        return isCompatible;
1813    }
1814    
1815    /**
1816     * Get the name of attribute holding the relation between a parent content and its children
1817     * @param parentProgramItem the parent content
1818     * @param childProgramItem the child content
1819     * @return the name of attribute the child relation
1820     */
1821    public String getDescendantRelationAttributeName(ProgramItem parentProgramItem, ProgramItem childProgramItem)
1822    {
1823        if (parentProgramItem instanceof CourseList && childProgramItem instanceof Course)
1824        {
1825            return CourseList.CHILD_COURSES;
1826        }
1827        else if (parentProgramItem instanceof Course && childProgramItem instanceof CourseList)
1828        {
1829            return Course.CHILD_COURSE_LISTS;
1830        }
1831        else if (parentProgramItem instanceof Course && childProgramItem instanceof CoursePart)
1832        {
1833            return Course.CHILD_COURSE_PARTS;
1834        }
1835        else if (parentProgramItem instanceof TraversableProgramPart && childProgramItem instanceof ProgramPart)
1836        {
1837            return TraversableProgramPart.CHILD_PROGRAM_PARTS;
1838        }
1839        
1840        return null;
1841    }
1842    
1843    private boolean _isCourseAlreadyBelongToCourseList(Course course, CourseList courseList)
1844    {
1845        return courseList.getCourses().contains(course);
1846    }
1847    
1848    private boolean _isContentTypeCompatible(Content srcContent, Content targetContent)
1849    {
1850        if (srcContent instanceof Container || srcContent instanceof SubProgram)
1851        {
1852            return targetContent instanceof AbstractTraversableProgramPart;
1853        }
1854        else if (srcContent instanceof CourseList)
1855        {
1856            return targetContent instanceof CourseListContainer;
1857        }
1858        else if (srcContent instanceof Course)
1859        {
1860            return targetContent instanceof CourseList;
1861        }
1862        else if (srcContent instanceof OrgUnit)
1863        {
1864            return targetContent instanceof OrgUnit;
1865        }
1866        
1867        return false;
1868    }
1869    
1870    private boolean _isCatalogCompatible(Content srcContent, Content targetContent)
1871    {
1872        if (srcContent instanceof ProgramItem srcProgramItem && targetContent instanceof ProgramItem targetProgramItem)
1873        {
1874            return srcProgramItem.getCatalog().equals(targetProgramItem.getCatalog());
1875        }
1876        return true;
1877    }
1878    
1879    private boolean _isLanguageCompatible(Content srcContent, Content targetContent)
1880    {
1881        return srcContent.getLanguage().equals(targetContent.getLanguage());
1882    }
1883    
1884    private boolean _areShareableFieldsCompatibles(Content srcContent, Content targetContent, Map<String, Object> contextualParameters)
1885    {
1886        // We check shareable fields only if the course content is not created (or created by copy) and not moved
1887        if (srcContent instanceof Course srcCourse
1888                && targetContent instanceof CourseList targetCourseList
1889                && _shareableCourseHelper.handleShareableCourse()
1890                && !"create".equals(contextualParameters.get("mode"))
1891                && !"copy".equals(contextualParameters.get("mode"))
1892                && !"move".equals(contextualParameters.get("mode"))
1893                // In this case, it means that we try to change the position of the course in the courseList, so don't check shareable fields
1894                && !_isCourseAlreadyBelongToCourseList(srcCourse, targetCourseList))
1895        {
1896            return _shareableCourseHelper.isShareableFieldsMatch(srcCourse, targetCourseList);
1897        }
1898        
1899        return true;
1900    }
1901    
1902    private List<String> _getContentParameters(Content srcContent, Content targetContent)
1903    {
1904        List<String> parameters = new ArrayList<>();
1905        parameters.add(srcContent.getTitle());
1906        parameters.add(srcContent.getId());
1907        parameters.add(targetContent.getTitle());
1908        parameters.add(targetContent.getId());
1909        return parameters;
1910    }
1911    /**
1912     * Copy a {@link ProgramItem}
1913     * @param srcContent The program item to copy
1914     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1915     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1916     * @param copiedContents the initial contents with their copied content
1917     * @return The created content
1918     * @param <C> The modifiable content return type
1919     * @throws AmetysRepositoryException If an error occurred during copy
1920     * @throws WorkflowException If an error occurred during copy
1921     */
1922    public <C extends ModifiableContent> C copyProgramItem(ProgramItem srcContent, String targetCatalog, boolean fullCopy, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
1923    {
1924        return copyProgramItem(srcContent, null, null, __INITIAL_WORKFLOW_ACTION_ID, targetCatalog, fullCopy, copiedContents);
1925    }
1926    
1927    /**
1928     * Copy a {@link ProgramItem}
1929     * @param srcContent The program item to copy
1930     * @param targetContentName The name of content to created. Can be null. If null, the new name will be get from the source object.
1931     * @param targetContentLanguage The name of content to created. Can be null. If null, the language of target content will be the same as source object.
1932     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1933     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1934     * @param copiedContents the initial contents with their copied content
1935     * @param <C> The modifiable content return type
1936     * @return The created content
1937     * @throws AmetysObjectExistsException If a program item with same code, catalog and language already exists
1938     * @throws AmetysRepositoryException If an error occurred
1939     * @throws WorkflowException If an error occurred
1940     */
1941    public <C extends ModifiableContent> C copyProgramItem(ProgramItem srcContent, String targetContentName, String targetContentLanguage, String targetCatalog, boolean fullCopy, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
1942    {
1943        return copyProgramItem(srcContent, targetContentName, targetContentLanguage, __INITIAL_WORKFLOW_ACTION_ID, targetCatalog, fullCopy, copiedContents);
1944    }
1945    
1946    /**
1947     * Copy a {@link CoursePart}
1948     * @param srcContent The course part to copy
1949     * @param targetContentName The name of content to created. Can be null. If null, the new name will be get from the source object.
1950     * @param targetContentLanguage The name of content to created. Can be null. If null, the language of target content will be the same as source object.
1951     * @param initWorkflowActionId The initial workflow action id
1952     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1953     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1954     * @param copiedContents the initial contents with their copied content
1955     * @param <C> The modifiable content return type
1956     * @return The created content
1957     * @throws AmetysObjectExistsException If a program item with same code, catalog and language already exists
1958     * @throws AmetysRepositoryException If an error occurred
1959     * @throws WorkflowException If an error occurred
1960     */
1961    public <C extends ModifiableContent> C copyCoursePart(CoursePart srcContent, String targetContentName, String targetContentLanguage, int initWorkflowActionId, String targetCatalog, boolean fullCopy, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
1962    {
1963        return _copyODFContent((Content) srcContent, srcContent.getCatalog(), srcContent.getCode(), targetContentName, targetContentLanguage, initWorkflowActionId, targetCatalog, fullCopy, copiedContents);
1964    }
1965    
1966    /**
1967     * Copy a {@link ProgramItem}
1968     * @param srcContent The program item to copy
1969     * @param targetContentName The name of content to created. Can be null. If null, the new name will be get from the source object.
1970     * @param targetContentLanguage The name of content to created. Can be null. If null, the language of target content will be the same as source object.
1971     * @param initWorkflowActionId The initial workflow action id
1972     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1973     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1974     * @param copiedContents the initial contents with their copied content
1975     * @param <C> The modifiable content return type
1976     * @return The created content
1977     * @throws AmetysObjectExistsException If a program item with same code, catalog and language already exists
1978     * @throws AmetysRepositoryException If an error occurred
1979     * @throws WorkflowException If an error occurred
1980     */
1981    public <C extends ModifiableContent> C copyProgramItem(ProgramItem srcContent, String targetContentName, String targetContentLanguage, int initWorkflowActionId, String targetCatalog, boolean fullCopy, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
1982    {
1983        return _copyODFContent((Content) srcContent, srcContent.getCatalog(), srcContent.getCode(), targetContentName, targetContentLanguage, initWorkflowActionId, targetCatalog, fullCopy, copiedContents);
1984    }
1985    
1986    /**
1987     * Copy a {@link ProgramItem}. Also copy the synchronization metadata (status and alternative value)
1988     * @param srcContent The program item to copy
1989     * @param catalog The catalog
1990     * @param code The odf content code
1991     * @param targetContentName The name of content to created. Can be null. If null, the new name will be get from the source object.
1992     * @param targetContentLanguage The name of content to created. Can be null. If null, the language of target content will be the same as source object.
1993     * @param initWorkflowActionId The initial workflow action id
1994     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1995     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1996     * @param copiedContents the initial contents with their copied content
1997     * @param <C> The modifiable content return type
1998     * @return The created content
1999     * @throws AmetysObjectExistsException If a program item with same code, catalog and language already exists
2000     * @throws AmetysRepositoryException If an error occurred
2001     * @throws WorkflowException If an error occurred
2002     */
2003    @SuppressWarnings("unchecked")
2004    private <C extends ModifiableContent> C _copyODFContent(Content srcContent, String catalog, String code, String targetContentName, String targetContentLanguage, int initWorkflowActionId, String targetCatalog, boolean fullCopy, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
2005    {
2006        String computedTargetLanguage = targetContentLanguage;
2007        if (computedTargetLanguage == null)
2008        {
2009            computedTargetLanguage = srcContent.getLanguage();
2010        }
2011        
2012        String computeTargetName = targetContentName;
2013        if (computeTargetName == null)
2014        {
2015            // Compute content name from source content and requested language
2016            computeTargetName = srcContent.getName() + (targetContentLanguage != null && !targetContentLanguage.equals(srcContent.getName()) ? "-" + targetContentLanguage : "");
2017        }
2018        
2019        String computeTargetCatalog = targetCatalog;
2020        if (computeTargetCatalog == null)
2021        {
2022            computeTargetCatalog = catalog;
2023        }
2024        
2025        String principalContentType = srcContent.getTypes()[0];
2026        ModifiableContent createdContent = getODFContent(principalContentType, code, computeTargetCatalog, computedTargetLanguage);
2027        if (createdContent != null)
2028        {
2029            getLogger().info("A program item already exists with the same type, code, catalog and language [{}, {}, {}, {}]", principalContentType, code, computeTargetCatalog, targetContentLanguage);
2030        }
2031        else
2032        {
2033            // Copy content without notifying observers (done later) and copying ACL
2034            DataContext context = RepositoryDataContext.newInstance()
2035                                                       .withExternalMetadataInCopy(true);
2036            createdContent = ((DefaultContent) srcContent).copyTo(getRootContent(true), computeTargetName, targetContentLanguage, initWorkflowActionId, false, true, false, true, context);
2037            
2038            if (fullCopy)
2039            {
2040                _cleanContentMetadata(createdContent);
2041                
2042                if (targetCatalog != null)
2043                {
2044                    if (createdContent instanceof ProgramItem programItem)
2045                    {
2046                        programItem.setCatalog(targetCatalog);
2047                    }
2048                    else if (createdContent instanceof CoursePart coursePart)
2049                    {
2050                        coursePart.setCatalog(targetCatalog);
2051                    }
2052                    
2053                }
2054                
2055                if (srcContent instanceof ProgramItem programItem)
2056                {
2057                    copyProgramItemStructure(programItem, createdContent, computedTargetLanguage, initWorkflowActionId, computeTargetCatalog, copiedContents);
2058                }
2059                
2060                _extractOutgoingReferences(createdContent);
2061                
2062                createdContent.saveChanges();
2063            }
2064            
2065            // Notify observers after all structure has been copied
2066            _contentDAO.notifyContentCopied(createdContent, false);
2067            
2068            copiedContents.put(srcContent, createdContent);
2069        }
2070        
2071        return (C) createdContent;
2072    }
2073    
2074    /**
2075     * Copy the structure of a {@link ProgramItem}
2076     * @param srcContent the content to copy
2077     * @param targetContent the target content
2078     * @param targetContentLanguage The name of content to created. Can be null. If null, the language of target content will be the same as source object.
2079     * @param initWorkflowActionId The initial workflow action id
2080     * @param targetCatalogName The target catalog. Can be null. The target catalog will be the catalog of the source object.
2081     * @param copiedContents the initial contents with their copied content
2082     * @throws AmetysRepositoryException If an error occurred during copy
2083     * @throws WorkflowException If an error occurred during copy
2084     */
2085    protected void copyProgramItemStructure(ProgramItem srcContent, ModifiableContent targetContent, String targetContentLanguage, int initWorkflowActionId, String targetCatalogName, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
2086    {
2087        List<ProgramItem> srcChildContents = new ArrayList<>();
2088        Map<Pair<String, String>, List<String>> values = new HashMap<>();
2089        
2090        String childMetadataPath = null;
2091        String parentMetadataPath = null;
2092        
2093        if (srcContent instanceof TraversableProgramPart programPart)
2094        {
2095            childMetadataPath = TraversableProgramPart.CHILD_PROGRAM_PARTS;
2096            parentMetadataPath = ProgramPart.PARENT_PROGRAM_PARTS;
2097            srcChildContents.addAll(programPart.getProgramPartChildren());
2098        }
2099        else if (srcContent instanceof CourseList courseList)
2100        {
2101            childMetadataPath = CourseList.CHILD_COURSES;
2102            parentMetadataPath = Course.PARENT_COURSE_LISTS;
2103            srcChildContents.addAll(courseList.getCourses());
2104        }
2105        else if (srcContent instanceof Course course)
2106        {
2107            childMetadataPath = Course.CHILD_COURSE_LISTS;
2108            parentMetadataPath = CourseList.PARENT_COURSES;
2109            srcChildContents.addAll(course.getCourseLists());
2110
2111            List<String> refCoursePartIds = new ArrayList<>();
2112            for (CoursePart srcChildContent : course.getCourseParts())
2113            {
2114                CoursePart targetChildContent = copyCoursePart(srcChildContent, null, targetContentLanguage, initWorkflowActionId, targetCatalogName, true, copiedContents);
2115                refCoursePartIds.add(targetChildContent.getId());
2116            }
2117            _addFormValues(values, Course.CHILD_COURSE_PARTS, CoursePart.PARENT_COURSES, refCoursePartIds);
2118        }
2119
2120        List<String> refChildIds = new ArrayList<>();
2121        for (ProgramItem srcChildContent : srcChildContents)
2122        {
2123            ProgramItem targetChildContent = copyProgramItem(srcChildContent, null, targetContentLanguage, initWorkflowActionId, targetCatalogName, true, copiedContents);
2124            refChildIds.add(targetChildContent.getId());
2125        }
2126
2127        _addFormValues(values, childMetadataPath, parentMetadataPath, refChildIds);
2128
2129        _editChildRelation((ModifiableWorkflowAwareContent) targetContent, values);
2130    }
2131    
2132    private void _addFormValues(Map<Pair<String, String>, List<String>> values, String childMetadataPath, String parentMetadataPath, List<String> refChildIds)
2133    {
2134        if (!refChildIds.isEmpty())
2135        {
2136            values.put(Pair.of(childMetadataPath, parentMetadataPath), refChildIds);
2137        }
2138    }
2139    
2140    private void _editChildRelation(ModifiableWorkflowAwareContent parentContent, Map<Pair<String, String>, List<String>> values) throws AmetysRepositoryException
2141    {
2142        if (!values.isEmpty())
2143        {
2144            for (Map.Entry<Pair<String, String>, List<String>> entry : values.entrySet())
2145            {
2146                String childMetadataName = entry.getKey().getLeft();
2147                String parentMetadataName = entry.getKey().getRight();
2148                List<String> childContents = entry.getValue();
2149                
2150                parentContent.setValue(childMetadataName, childContents.toArray(new String[childContents.size()]));
2151                
2152                for (String childContentId : childContents)
2153                {
2154                    ModifiableContent content = _resolver.resolveById(childContentId);
2155                    String[] parentContentIds = ContentDataHelper.getContentIdsArrayFromMultipleContentData(content, parentMetadataName);
2156                    content.setValue(parentMetadataName, ArrayUtils.add(parentContentIds, parentContent.getId()));
2157                    content.saveChanges();
2158                }
2159            }
2160        }
2161    }
2162    
2163    /**
2164     * Clean the CONTENT metadata created after a copy but whose values reference the initial content' structure
2165     * @param createdContent The created content to clean
2166     */
2167    protected void _cleanContentMetadata(ModifiableContent createdContent)
2168    {
2169        if (createdContent instanceof ProgramPart)
2170        {
2171            _removeFullValue(createdContent, ProgramPart.PARENT_PROGRAM_PARTS);
2172        }
2173        
2174        if (createdContent instanceof TraversableProgramPart)
2175        {
2176            _removeFullValue(createdContent, TraversableProgramPart.CHILD_PROGRAM_PARTS);
2177        }
2178        
2179        if (createdContent instanceof CourseList)
2180        {
2181            _removeFullValue(createdContent, CourseList.CHILD_COURSES);
2182            _removeFullValue(createdContent, CourseList.PARENT_COURSES);
2183        }
2184        
2185        if (createdContent instanceof Course)
2186        {
2187            _removeFullValue(createdContent, Course.CHILD_COURSE_LISTS);
2188            _removeFullValue(createdContent, Course.PARENT_COURSE_LISTS);
2189            _removeFullValue(createdContent, Course.CHILD_COURSE_PARTS);
2190        }
2191        
2192        if (createdContent instanceof CoursePart)
2193        {
2194            _removeFullValue(createdContent, CoursePart.PARENT_COURSES);
2195        }
2196    }
2197    
2198    private void _removeFullValue(ModifiableContent content, String attributeName)
2199    {
2200        content.removeValue(attributeName);
2201        content.removeExternalizableMetadataIfExists(attributeName);
2202    }
2203    
2204    private void _extractOutgoingReferences(ModifiableContent content)
2205    {
2206        Map<String, OutgoingReferences> outgoingReferencesByPath = _outgoingReferencesExtractor.getOutgoingReferences(content);
2207        content.setOutgoingReferences(outgoingReferencesByPath);
2208    }
2209    
2210    /**
2211     * Switch the ametys object to Live version if it has one
2212     * @param ao the Ametys object
2213     * @throws NoLiveVersionException if the content has no live version
2214     */
2215    public void switchToLiveVersion(DefaultAmetysObject ao) throws NoLiveVersionException
2216    {
2217        // Switch to the Live label if exists
2218        String[] allLabels = ao.getAllLabels();
2219        String[] currentLabels = ao.getLabels();
2220        
2221        boolean hasLiveVersion = Arrays.asList(allLabels).contains(CmsConstants.LIVE_LABEL);
2222        boolean currentVersionIsLive = Arrays.asList(currentLabels).contains(CmsConstants.LIVE_LABEL);
2223        
2224        if (hasLiveVersion && !currentVersionIsLive)
2225        {
2226            ao.switchToLabel(CmsConstants.LIVE_LABEL);
2227        }
2228        else if (!hasLiveVersion)
2229        {
2230            throw new NoLiveVersionException("The ametys object '" + ao.getId() + "' has no live version");
2231        }
2232    }
2233    
2234    /**
2235     * Switch to Live version if is required
2236     * @param ao the Ametys object
2237     * @throws NoLiveVersionException if the Live version is required but not exist
2238     */
2239    public void switchToLiveVersionIfNeeded(DefaultAmetysObject ao) throws NoLiveVersionException
2240    {
2241        Request request = _getRequest();
2242        if (request != null && request.getAttribute(REQUEST_ATTRIBUTE_VALID_LABEL) != null)
2243        {
2244            switchToLiveVersion(ao);
2245        }
2246    }
2247    
2248    /**
2249     * Count the hours accumulation in the {@link ProgramItem}
2250     * @param programItem The program item on which we compute the total number of hours
2251     * @return The hours accumulation
2252     */
2253    public Double getCumulatedHours(ProgramItem programItem)
2254    {
2255        // Ignore optional course list and avoid useless expensive calls
2256        if (programItem instanceof CourseList courseList && ChoiceType.OPTIONAL.equals(courseList.getType()))
2257        {
2258            return 0.0;
2259        }
2260
2261        List<ProgramItem> children = getChildProgramItems(programItem);
2262
2263        Double coef = 1.0;
2264        Double countNbHours = 0.0;
2265
2266        // If the program item is a course list, compute the coef (mandatory: 1, optional: 0, optional: min / total)
2267        if (programItem instanceof CourseList courseList)
2268        {
2269            // If there is no children, compute the coef is useless
2270            // Also choice list can throw an exception while dividing by zero
2271            if (children.isEmpty())
2272            {
2273                return 0.0;
2274            }
2275            
2276            switch (courseList.getType())
2277            {
2278                case CHOICE:
2279                    // Apply the average of number of EC from children multiply by the minimum ELP to select
2280                    coef = ((double) courseList.getMinNumberOfCourses()) / children.size();
2281                    break;
2282                case MANDATORY:
2283                default:
2284                    // Add all ECTS from children
2285                    break;
2286            }
2287        }
2288
2289        // If it's a course and we have a value for the number of hours
2290        // Then get the value
2291        if (programItem instanceof Course course && course.hasValue(Course.NUMBER_OF_HOURS))
2292        {
2293            countNbHours += course.<Double>getValue(Course.NUMBER_OF_HOURS);
2294        }
2295        // Else if there are program item children on the item
2296        // Then compute on children
2297        else if (children.size() > 0)
2298        {
2299            for (ProgramItem child : children)
2300            {
2301                countNbHours += getCumulatedHours(child);
2302            }
2303        }
2304        // Else, it's a course but there is no value for the number of hours and we don't have program item children
2305        // Then compute on course parts
2306        else if (programItem instanceof Course course)
2307        {
2308            countNbHours += course.getCourseParts()
2309                .stream()
2310                .mapToDouble(CoursePart::getNumberOfHours)
2311                .sum();
2312        }
2313        
2314        return coef * countNbHours;
2315    }
2316    
2317    /**
2318     * Get the request
2319     * @return the request
2320     */
2321    protected Request _getRequest()
2322    {
2323        return ContextHelper.getRequest(_context);
2324    }
2325    
2326    /**
2327     * Get the first orgunit matching the given UAI code
2328     * @param uaiCode the UAI code
2329     * @return the orgunit or null if not found
2330     */
2331    public OrgUnit getOrgUnitByUAICode(String uaiCode)
2332    {
2333        Expression expr = new AndExpression(
2334                new ContentTypeExpression(Operator.EQ, OrgUnitFactory.ORGUNIT_CONTENT_TYPE),
2335                new StringExpression(OrgUnit.CODE_UAI, Operator.EQ, uaiCode)
2336        );
2337        
2338        String xPathQuery = QueryHelper.getXPathQuery(null, OrgUnitFactory.ORGUNIT_NODETYPE, expr);
2339        AmetysObjectIterable<OrgUnit> orgUnits = _resolver.query(xPathQuery);
2340        
2341        return orgUnits.stream()
2342            .findFirst()
2343            .orElse(null);
2344    }
2345    
2346    /**
2347     * Get the repeater entries filtered by path, it takes care of "common" attribute if exists and find itself the educational-path attribute.
2348     * @param repeater The repeater to filter
2349     * @param educationalPaths List of full educational paths
2350     * @return a {@link Stream} of filtered repeater entries
2351     */
2352    public Stream<? extends ModelAwareRepeaterEntry> getRepeaterEntriesByPath(ModelAwareRepeater repeater, List<EducationalPath> educationalPaths)
2353    {
2354        // Build the filter to apply on repeater entries
2355        Predicate<ModelAwareRepeaterEntry> filterRepeaterEntries =  _buildRepeaterEntryByPathPredicate(repeater, educationalPaths);
2356        
2357        // If predicate is null, an error has been logged
2358        if (filterRepeaterEntries == null)
2359        {
2360            return Stream.empty();
2361        }
2362        
2363        // For each entry, check if the entry is common (if attribute exists) or the path correspond to one of the retrieved full educational paths
2364        return repeater.getEntries()
2365            .stream()
2366            .filter(filterRepeaterEntries);
2367    }
2368    
2369    private Predicate<ModelAwareRepeaterEntry> _buildRepeaterEntryByPathPredicate(ModelAwareRepeater repeater, List<EducationalPath> educationalPaths)
2370    {
2371        RepeaterDefinition repeaterModel = repeater.getModel();
2372        
2373        Predicate<ModelItem> keepSinglePaths = modelItem -> {
2374            // Retrieve the path of the parent to check if it is a multiple attribute (remove the repeater path at the beginning of the path, because it would always be multiple otherwise)
2375            // The parent cannot be null because we know that the repeater is the parent
2376            String path = StringUtils.substringAfter(modelItem.getParent().getPath(), repeaterModel.getPath() + ModelItem.ITEM_PATH_SEPARATOR);
2377            
2378            // If the path is empty, that means the attribute is at the first level of the repeater, so it can be used
2379            return StringUtils.isEmpty(path) || !DataHolderHelper.isMultiple(repeaterModel, path);
2380        };
2381        
2382        List<ModelItem> educationalPathModelItem = ModelHelper.findModelItemsByType(repeaterModel, EducationalPathRepositoryElementType.EDUCATIONAL_PATH_ELEMENT_TYPE_ID)
2383                               .stream()
2384                               // If the item containing the path is multiple, we cannot use the path to filter the repeater entries, so exclude them
2385                               .filter(keepSinglePaths)
2386                               .toList();
2387        
2388        if (educationalPathModelItem.size() != 1)
2389        {
2390            getLogger().error("Unable to determine repeater entry matching an education path. No attribute or several attributes of type '{}' found.", EducationalPathRepositoryElementType.EDUCATIONAL_PATH_ELEMENT_TYPE_ID, repeaterModel.getPath());
2391            return null;
2392        }
2393        
2394        // Prepare the predicate for educational path attribute
2395        String pathAttributeName = educationalPathModelItem.get(0).getName();
2396        Predicate<ModelAwareRepeaterEntry> repeaterEntriesFilter = e -> educationalPaths.contains(e.getValue(pathAttributeName, false, null));
2397        
2398        // Complete predicate with common attribute if exists
2399        if (repeater.getModel().hasModelItem("common"))
2400        {
2401            return ((Predicate<ModelAwareRepeaterEntry>) e -> e.getValue("common", true, true)).or(repeaterEntriesFilter);
2402        }
2403        
2404        return repeaterEntriesFilter;
2405    }
2406    
2407    /**
2408     * Determine if the content is a container of nature equals to "annee"
2409     * @param content The content
2410     * @return <code>true</code> if the current content item is a container of nature equals to "annee"
2411     */
2412    public boolean isContainerOfTypeYear(Content content)
2413    {
2414        return content instanceof Container container && isContainerOfTypeYear(container);
2415    }
2416    
2417    /**
2418     * Determine if the container nature equals to "annee"
2419     * @param containerId The container id
2420     * @return <code>true</code> if the current container nature equals to "annee"
2421     */
2422    @Callable (rights = Callable.NO_CHECK_REQUIRED)
2423    public boolean isContainerOfTypeYear(String containerId)
2424    {
2425        Container container = _resolver.resolveById(containerId);
2426        return isContainerOfTypeYear(container);
2427    }
2428    
2429    /**
2430     * Determine if the container nature equals to "annee"
2431     * @param container The container
2432     * @return <code>true</code> if the current container nature equals to "annee"
2433     */
2434    public boolean isContainerOfTypeYear(Container container)
2435    {
2436        return getYearId()
2437                .map(id -> StringUtils.equals(id, container.getNature()))
2438                .orElse(false);
2439    }
2440    
2441    /**
2442     * Get the year container nature identifier.
2443     * @return an {@link Optional} of the year identifier
2444     */
2445    public synchronized Optional<String> getYearId()
2446    {
2447        if (_yearId.isEmpty())
2448        {
2449            _yearId = Optional.of(_refTableHelper)
2450                .map(rth -> rth.getItemFromCode(OdfReferenceTableHelper.CONTAINER_NATURE, "annee"))
2451                .map(OdfReferenceTableEntry::getId)
2452                .filter(StringUtils::isNotBlank);
2453        }
2454        return _yearId;
2455    }
2456    
2457    /**
2458     * Get all the years of a program part, search in children.
2459     * @param programPart The program part
2460     * @return A set of {@link Container} with the year nature.
2461     */
2462    public Set<Container> getYears(TraversableProgramPart programPart)
2463    {
2464        Set<Container> years = new LinkedHashSet<>();
2465        
2466        if (programPart instanceof Container container && isContainerOfTypeYear(container))
2467        {
2468            years.add(container);
2469        }
2470        
2471        programPart.getProgramPartChildren()
2472                   .stream()
2473                   .filter(TraversableProgramPart.class::isInstance)
2474                   .map(TraversableProgramPart.class::cast)
2475                   .map(this::getYears)
2476                   .flatMap(Set::stream)
2477                   .forEach(years::add);
2478        
2479        return years;
2480    }
2481    
2482    /**
2483     * Filter the program item to keep only container with the given nature.
2484     * @param programItem The program item
2485     * @param natureId The container nature identifier
2486     * @return <code>true</code> if it is a container of the given nature, <code>false</code> otherwise
2487     */
2488    public boolean isContainerOfNature(ProgramItem programItem, String natureId)
2489    {
2490        return Optional.of(programItem)
2491            .filter(Container.class::isInstance)
2492            .map(Container.class::cast)
2493            .map(Container::getNature)
2494            .map(natureId::equals)
2495            .orElse(false);
2496    }
2497}