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}