001/*
002 *  Copyright 2026 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.odforientation;
017
018import java.io.BufferedReader;
019import java.io.IOException;
020import java.io.InputStream;
021import java.io.InputStreamReader;
022import java.nio.charset.StandardCharsets;
023import java.util.ArrayList;
024import java.util.Collections;
025import java.util.List;
026import java.util.Map;
027
028import org.apache.avalon.framework.component.Component;
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.avalon.framework.service.Serviceable;
032import org.apache.commons.lang3.StringUtils;
033import org.apache.excalibur.source.Source;
034import org.apache.excalibur.source.SourceResolver;
035
036import org.ametys.cms.data.ContentValue;
037import org.ametys.cms.repository.Content;
038import org.ametys.cms.transformation.xslt.ResolveURIComponent;
039import org.ametys.core.ui.Callable;
040import org.ametys.core.util.JSONUtils;
041import org.ametys.odf.ODFHelper;
042import org.ametys.odf.catalog.CatalogsManager;
043import org.ametys.odf.enumeration.OdfReferenceTableEntry;
044import org.ametys.odf.enumeration.OdfReferenceTableHelper;
045import org.ametys.odf.program.AbstractProgram;
046import org.ametys.odf.program.Program;
047import org.ametys.odf.program.ProgramFactory;
048import org.ametys.plugins.repository.AmetysObjectIterable;
049import org.ametys.plugins.repository.AmetysObjectResolver;
050import org.ametys.plugins.repository.query.SortCriteria;
051import org.ametys.plugins.repository.query.expression.Expression;
052import org.ametys.plugins.repository.query.expression.Expression.Operator;
053import org.ametys.plugins.repository.query.expression.StringExpression;
054import org.ametys.runtime.plugin.component.AbstractLogEnabled;
055
056/**
057 * Helper for program flow data
058 * This class is used to provide data for the students flow by degrees and study fields.
059 */
060public class ProgramFlowHelper extends AbstractLogEnabled implements Component, Serviceable
061{
062    /** The component role. */
063    public static final String ROLE = ProgramFlowHelper.class.getName();
064    
065    /** Study field */
066    public static final String STUDY_FIELD = "odf-enumeration.StudyField";
067    
068    /** Study field */
069    public static final String STUDY_FIELD_ATTRIBUTE_NAME = "studyField";
070    
071    private static final String __PROGRAM_FLOW_CONF_FILE = "context://WEB-INF/param/odf/program-flow.json";
072    
073    private SourceResolver _srcResolver;
074    private JSONUtils _jsonUtils;
075
076    private OdfReferenceTableHelper _odfReferenceTableHelper;
077
078    private ODFHelper _odfHelper;
079
080    private AmetysObjectResolver _resolver;
081
082    private CatalogsManager _catalogManager;
083    
084    public void service(ServiceManager manager) throws ServiceException
085    {
086        _srcResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
087        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
088        _odfReferenceTableHelper = (OdfReferenceTableHelper) manager.lookup(OdfReferenceTableHelper.ROLE);
089        _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE);
090        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
091        _catalogManager = (CatalogsManager) manager.lookup(CatalogsManager.ROLE);
092    }
093
094    /**
095     * Get the data for programs pathways, including the flow and the study fields.
096     * @param catalogName The name of the catalog. If null the default catalog will be used.
097     * @param lang The program's language. Cannot not be null nor empty.
098     * @return the data for programs pathways as json
099     */
100    @Callable (allowAnonymous = true, rights = Callable.NO_CHECK_REQUIRED)
101    public Map<String, Object> getProgramFlowData(String catalogName, String lang)
102    {
103        if (StringUtils.isEmpty(lang))
104        {
105            throw new IllegalArgumentException("Missing language argument to get programs pathways data");
106        }
107        
108        String catalog = StringUtils.defaultIfEmpty(catalogName, _catalogManager.getDefaultCatalog().getName());
109        
110        // Concat student flow by degrees and study fields
111        Map<String, Object> flow = _buildStudentsFlowByDegrees(catalog, lang);
112        
113        // Remove degrees without programs
114        if (flow.containsKey("degrees"))
115        {
116            @SuppressWarnings("unchecked")
117            List<Map<String, Object>> degrees = (List<Map<String, Object>>) flow.get("degrees");
118            degrees.removeIf(degree -> {
119                @SuppressWarnings("unchecked")
120                Map<String, List<Map<String, Object>>> programsByField = (Map<String, List<Map<String, Object>>>) degree.get("programsByField");
121                return programsByField == null || programsByField.isEmpty();
122            });
123        }
124        
125        List<Map<String, Object>> fields = _buildStudyFields(lang);
126        flow.put("studyFields", fields);
127        return flow;
128    }
129    
130    private List<Map<String, Object>> _buildStudyFields(String lang)
131    {
132        List<Map<String, Object>> fields = new ArrayList<>();
133        _odfReferenceTableHelper.getItems(STUDY_FIELD).forEach(entry -> {
134            Content entryContent = _resolver.resolveById(entry.getId());
135            fields.add(Map.of("id", entry.getId(), 
136                              "label", entry.getLabel(lang),
137                              "code", entry.getCode(),
138                              "icon", entryContent.getValueOrDefault("icon", StringUtils.EMPTY)));
139        });
140
141        return fields;
142    }
143    
144    private Map<String, Object> _buildStudentsFlowByDegrees(String catalogName, String lang)
145    {
146        Map<String, Object> flow = getProgramFlowByDegrees();
147        
148        if (flow.containsKey("degrees"))
149        {
150            @SuppressWarnings("unchecked")
151            List<Map<String, Object>> degrees = (List<Map<String, Object>>) flow.get("degrees");
152            for (Map<String, Object> degree: degrees)
153            {
154                String degreeCode = (String) degree.get("id");
155                OdfReferenceTableEntry degreeEntry = _odfReferenceTableHelper.getItemFromCode(OdfReferenceTableHelper.DEGREE, degreeCode);
156                
157                if (degreeEntry == null)
158                {
159                    getLogger().warn("No degree found with code '" + degreeCode + "'. No program will be associated to this degree in the students flow.");
160                    continue;
161                }
162
163                degree.put("contentId", degreeEntry.getId());
164                
165                // Get programs for this degree, classified by study field
166                Map<String, List<Map<String, Object>>> programsByField = _buildProgramsByStudyField(degreeEntry, catalogName, lang);
167                degree.put("programsByField", programsByField);
168                    
169                Content degreeContent = _resolver.resolveById(degreeEntry.getId());
170                degree.put("startYear", degreeContent.getValueOrDefault("startYear", -1L));
171                degree.put("durationYears", degreeContent.getValueOrDefault("duration", -1L));
172                degree.put("description", degreeContent.getValueOrDefault("description", StringUtils.EMPTY));
173            }
174        }
175        
176        return flow;
177    }
178    
179    private Map<String, List<Map<String, Object>>> _buildProgramsByStudyField(OdfReferenceTableEntry degreeEntry, String catalogName, String lang)
180    {
181        Expression degreeExpr = new StringExpression(AbstractProgram.DEGREE, Operator.EQ, degreeEntry.getId());
182        
183        SortCriteria sortCriteria = new SortCriteria();
184        sortCriteria.addCriterion(Content.ATTRIBUTE_TITLE, true, true);
185        
186        AmetysObjectIterable<Program> programItems = _odfHelper.getProgramItems(ProgramFactory.PROGRAM_CONTENT_TYPE, null, catalogName, lang, degreeExpr, sortCriteria);
187        
188        Map<String, List<Map<String, Object>>> programsByField = new java.util.HashMap<>();
189        
190        programItems.forEach(program -> {
191            ContentValue cValue = program.getValueOrDefault(STUDY_FIELD_ATTRIBUTE_NAME, null);
192            String fieldCode = "__NONE__";
193            if (cValue != null)
194            {
195                fieldCode = cValue.getContent().getValue(OdfReferenceTableEntry.CODE);
196            }
197            
198            programsByField.computeIfAbsent(fieldCode, k -> new ArrayList<>()).add(Map.of("id", program.getId(),
199                                                                                 "title", program.getTitle(),
200                                                                                 "url", ResolveURIComponent.resolve("odf", program.getId())));
201        });
202        
203        return programsByField;
204    }
205    
206    /**
207     * Get the students flow by degrees
208     * @return the students flow
209     */
210    public Map<String, Object> getProgramFlowByDegrees()
211    {
212        Source source = null;
213        try
214        {
215            source = _srcResolver.resolveURI(__PROGRAM_FLOW_CONF_FILE);
216            if (source.exists())
217            {
218                try (InputStream inputStream = source.getInputStream())
219                {
220                    String json = _readInputStream(inputStream);
221                    return _jsonUtils.convertJsonToMap(json);
222                }
223            }
224            else
225            {
226                getLogger().info("There is no configuration file at path '" + __PROGRAM_FLOW_CONF_FILE + "'");
227                return Collections.emptyMap();
228            }
229        }
230        catch (IOException e)
231        {
232            getLogger().error("Unable to read JSON file: " + __PROGRAM_FLOW_CONF_FILE, e);
233            return Collections.emptyMap();
234        }
235        finally
236        {
237            if (_srcResolver != null && source != null)
238            {
239                _srcResolver.release(source);
240            }
241        }
242    }
243    
244    private String _readInputStream(InputStream is) throws IOException
245    {
246        StringBuilder content = new StringBuilder();
247
248        try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)))
249        {
250            String line;
251            while ((line = reader.readLine()) != null)
252            {
253                content.append(line).append('\n');
254            }
255        }
256
257        return content.toString();
258    }
259}