001/* 002 * Copyright 2022 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.hyperplanning; 017 018import java.time.Duration; 019import java.time.LocalDate; 020import java.time.LocalDateTime; 021import java.time.format.DateTimeFormatter; 022import java.time.temporal.ChronoUnit; 023import java.util.ArrayList; 024import java.util.Comparator; 025import java.util.HashMap; 026import java.util.List; 027import java.util.Map; 028import java.util.Objects; 029import java.util.Optional; 030import java.util.function.Predicate; 031 032import org.apache.avalon.framework.activity.Initializable; 033import org.apache.avalon.framework.component.Component; 034import org.apache.avalon.framework.service.ServiceException; 035import org.apache.avalon.framework.service.ServiceManager; 036import org.apache.avalon.framework.service.Serviceable; 037 038import org.ametys.core.cache.AbstractCacheManager; 039import org.ametys.core.cache.Cache; 040import org.ametys.core.user.UserIdentity; 041import org.ametys.core.util.HttpUtils; 042import org.ametys.runtime.config.Config; 043import org.ametys.runtime.i18n.I18nizableText; 044import org.ametys.runtime.plugin.component.AbstractLogEnabled; 045 046import com.indexeducation.hyperplanning.ApiClient; 047import com.indexeducation.hyperplanning.ApiException; 048import com.indexeducation.hyperplanning.api.CoursAnnulesApi; 049import com.indexeducation.hyperplanning.api.CoursApi; 050import com.indexeducation.hyperplanning.api.EtudiantsApi; 051import com.indexeducation.hyperplanning.api.MatieresApi; 052import com.indexeducation.hyperplanning.model.Cours; 053import com.indexeducation.hyperplanning.model.CoursAnnules; 054import com.indexeducation.hyperplanning.model.CoursAnnulesCleDetailSeancesPlaceesGet200ResponseInner; 055import com.indexeducation.hyperplanning.model.Etudiants; 056import com.indexeducation.hyperplanning.model.Matieres; 057 058/** 059 * Component handling the communication with a remote hyperplanning server 060 */ 061public class HyperplanningManager extends AbstractLogEnabled implements Initializable, Component, Serviceable 062{ 063 /** The avalon role */ 064 public static final String ROLE = HyperplanningManager.class.getName(); 065 private static final String __CANCELLED_LESSONS_CACHE = HyperplanningManager.class.getName() + "$cancelledLessons"; 066 private static final String __STUDENT_ICAL_CACHE = HyperplanningManager.class.getName() + "$studentsIcals"; 067 private static final String __CAS_IDENTIFIER_CACHE = HyperplanningManager.class.getName() + "$casIdentifiers"; 068 069 /* Reduce the set of retrieved data to avoid the modifiant field that is wrongly defined in swagger */ 070 private static final List<String> __COURS_ANNULES_SELECT = List.of("cle", "matiere", "commentaire", "motif_annulation", "date_heure_annulation"); 071 private static final Comparator<CancelledLesson> __CANCELLED_LESSON_CHRONO_COMPARATOR = (c1, c2) -> c1.lessonDate().compareTo(c2.lessonDate()); 072 073 private String _connectionLogin; 074 private String _connectionPass; 075 private String _serverUrl; 076 077 private CoursApi _coursApi; 078 private CoursAnnulesApi _coursAnnulesApi; 079 private EtudiantsApi _etudiantsApi; 080 private MatieresApi _matiereApi; 081 082 private AbstractCacheManager _cacheManager; 083 084 public void service(ServiceManager manager) throws ServiceException 085 { 086 _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE); 087 } 088 089 public void initialize() throws Exception 090 { 091 _connectionLogin = Config.getInstance().getValue("org.ametys.plugins.hyperplanning.login"); 092 _connectionPass = Config.getInstance().getValue("org.ametys.plugins.hyperplanning.password"); 093 _serverUrl = HttpUtils.sanitize(Config.getInstance().getValue("org.ametys.plugins.hyperplanning.url")); 094 095 _cacheManager.createMemoryCache(__CANCELLED_LESSONS_CACHE, 096 new I18nizableText("plugin.hyperplanning", "PLUGIN_HYPERPLANNING_CANCELLED_LESSONS_CACHE_LABEL"), 097 new I18nizableText("plugin.hyperplanning", "PLUGIN_HYPERPLANNING_CANCELLED_LESSONS_CACHE_DESCRIPTION"), 098 true, 099 Duration.ofMinutes(Config.getInstance().getValue("org.ametys.plugins.hyperplanning.cache-validity"))); 100 101 _cacheManager.createMemoryCache(__STUDENT_ICAL_CACHE, 102 new I18nizableText("plugin.hyperplanning", "PLUGIN_HYPERPLANNING_STUDENT_ICALS_CACHE_LABEL"), 103 new I18nizableText("plugin.hyperplanning", "PLUGIN_HYPERPLANNING_STUDENT_ICALS_CACHE_DESCRIPTION"), 104 true, 105 Duration.ofMinutes(Config.getInstance().getValue("org.ametys.plugins.hyperplanning.cache-validity"))); 106 107 _cacheManager.createMemoryCache(__CAS_IDENTIFIER_CACHE, 108 new I18nizableText("plugin.hyperplanning", "PLUGIN_HYPERPLANNING_CAS_IDENTIFIER_CACHE_LABEL"), 109 new I18nizableText("plugin.hyperplanning", "PLUGIN_HYPERPLANNING_CAS_IDENTIFIER_CACHE_DESCRIPTION"), 110 true, 111 Duration.ofMinutes(Config.getInstance().getValue("org.ametys.plugins.hyperplanning.cache-validity"))); 112 113 _initializeWebService(); 114 } 115 116 /** 117 * Get the timetable of a student 118 * @param userIdentity the student identity 119 * @return the timetable as an ICS URL 120 */ 121 public String getStudentIcal(UserIdentity userIdentity) 122 { 123 Cache<UserIdentity, String> cache = _cacheManager.get(__STUDENT_ICAL_CACHE); 124 return cache.get(userIdentity, this::_getStudentIcal); 125 } 126 127 128 /** 129 * Get the timetable of a student 130 * @param userIdentity the student identity 131 * @return the timetable as an ICS URL 132 */ 133 protected String _getStudentIcal(UserIdentity userIdentity) 134 { 135 try 136 { 137 Optional<String> etudiantCle = getEtudiantCle(userIdentity); 138 if (etudiantCle.isEmpty()) 139 { 140 return null; 141 } 142 143 return _serverUrl + "/" + _etudiantsApi.etudiantsCleGet(etudiantCle.get()).getUrlIcal(); 144 } 145 catch (ApiException e) 146 { 147 getLogger().error("Failed to retrieve Ical for user", e); 148 return null; 149 } 150 } 151 152 /** 153 * Get the list of cancelled lessons for a given user in the next 2 weeks 154 * @param userIdentity the user identity 155 * @return a list of {@link CancelledLesson} representing the cancelled lessons or null if the user is unknown 156 * @throws UnknownStudentException when user is not linked to hyperplanning 157 */ 158 public List<CancelledLesson> getUpcomingCancelledLessons(UserIdentity userIdentity) throws UnknownStudentException 159 { 160 if (userIdentity == null) 161 { 162 throw new IllegalArgumentException("User is not connected"); 163 } 164 165 List<CancelledLesson> cancelledLessons = getCancelledLessonsCache().get(userIdentity, this::_getCancelledLessons); 166 if (cancelledLessons == null) 167 { 168 throw new UnknownStudentException("User '" + userIdentity + "' has no link hyperplanning id"); 169 } 170 171 return cancelledLessons; 172 } 173 174 /** 175 * Get the list of cancelled lessons for a given user in the next 2 weeks 176 * @param userIdentity the user identity 177 * @return a list of {@link CancelledLesson} representing the cancelled lessons or null if the user is not linked to hyperplanning 178 */ 179 private List<CancelledLesson> _getCancelledLessons(UserIdentity userIdentity) 180 { 181 try 182 { 183 Optional<String> hypIdentity = getEtudiantCle(userIdentity); 184 if (hypIdentity.isEmpty()) 185 { 186 return null; 187 } 188 LocalDateTime startDate = LocalDate.now().atStartOfDay(); 189 LocalDateTime endDate = startDate.plusWeeks(2); 190 191 List<CoursAnnules> annulations = _coursAnnulesApi.coursAnnulesGet( 192 null, /* sort */ 193 __COURS_ANNULES_SELECT, /* select */ 194 null, /*cle*/ 195 null, /*matiere*/ 196 null, /* type */ 197 null, /* reference */ 198 startDate.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), /* start */ 199 endDate.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), /* end */ 200 null, /* enseignants */ 201 null, /* promotions */ 202 null, /* tdoption */ 203 null, /* regroupements */ 204 null, /* salles */ 205 List.of(hypIdentity.get())); /*etudiants*/ 206 207 List<CancelledLesson> result = new ArrayList<>(); 208 209 for (CoursAnnules annulation: annulations) 210 { 211 Integer cleMatiere = annulation.getMatiere(); 212 // fetch the matiere once and for all 213 Matieres matiere = _matiereApi.matieresCleGet(cleMatiere.toString()); 214 LocalDateTime dateAnnulation = LocalDateTime.parse(annulation.getDateHeureAnnulation()); 215 216 // A CoursAnnules contains a list of Seances 217 // Fetch this list and restrict it to our time frame 218 List<CoursAnnulesCleDetailSeancesPlaceesGet200ResponseInner> seances = _coursAnnulesApi.coursAnnulesCleDetailSeancesPlaceesGet(annulation.getCle()); 219 for (CoursAnnulesCleDetailSeancesPlaceesGet200ResponseInner seance : seances) 220 { 221 LocalDateTime dateSeance = LocalDateTime.parse(seance.getJourHeureDebut()); 222 if (dateSeance.isAfter(startDate) && dateSeance.isBefore(endDate)) 223 { 224 result.add(new CancelledLesson( 225 matiere.getCode(), 226 matiere.getLibelle(), 227 matiere.getLibelleLong(), 228 dateSeance, 229 annulation.getMotifAnnulation(), 230 annulation.getCommentaire(), 231 dateAnnulation 232 )); 233 } 234 } 235 } 236 237 result.sort(__CANCELLED_LESSON_CHRONO_COMPARATOR); 238 return result; 239 } 240 catch (ApiException e) 241 { 242 getLogger().error("An error occured while contacting Hyperplanning", e); 243 return null; 244 } 245 } 246 247 /** 248 * Get the list of cancelled lessons and the impacted students. 249 * @param cancellationMinDate a date to only return lessons that were cancelled after that date, or null. 250 * @param lessonMinDate a date to only return lessons that start after that date, or null. 251 * @param lessonMaxDate a date to only return lessons that start before that date, or null. 252 * @return a map of cancelled lessons with the impacted CAS identifier 253 */ 254 public Map<CancelledLesson, List<String>> getCancelledLessons(LocalDateTime cancellationMinDate, LocalDateTime lessonMinDate, LocalDateTime lessonMaxDate) 255 { 256 Predicate<CoursAnnules> cancellationMinDatePredicate = _getCancellationDateFilter(cancellationMinDate); 257 258 Map<CancelledLesson, List<String>> result = new HashMap<>(); 259 try 260 { 261 String minDate = lessonMinDate != null ? lessonMinDate.truncatedTo(ChronoUnit.SECONDS).format(DateTimeFormatter.ISO_DATE_TIME) : null; 262 String maxDate = lessonMaxDate != null ? lessonMaxDate.truncatedTo(ChronoUnit.SECONDS).format(DateTimeFormatter.ISO_DATE_TIME) : null; 263 List<CoursAnnules> coursAnnules = _coursAnnulesApi.coursAnnulesGet(null, __COURS_ANNULES_SELECT, null, null, null, null, minDate, maxDate, null, null, null, null, null, null); 264 for (CoursAnnules coursAnnule : coursAnnules) 265 { 266 // Keep only cancellation that match the cancellation date filter 267 if (cancellationMinDatePredicate.test(coursAnnule)) 268 { 269 Matieres matiere = _matiereApi.matieresCleGet(coursAnnule.getMatiere().toString()); 270 271 Cours cours = null; 272 // Get the cancelled lessons 273 List<CoursAnnulesCleDetailSeancesPlaceesGet200ResponseInner> seances = _coursAnnulesApi.coursAnnulesCleDetailSeancesPlaceesGet(coursAnnule.getCle()); 274 for (CoursAnnulesCleDetailSeancesPlaceesGet200ResponseInner seance :seances) 275 { 276 // filter again to keep only lesson that occurs in the time frame 277 // (when multiple lessons for the same course are cancelled together, they all appears in this list. 278 LocalDateTime dateSeance = LocalDateTime.parse(seance.getJourHeureDebut(), DateTimeFormatter.ISO_DATE_TIME); 279 if ((lessonMinDate == null || dateSeance.isAfter(lessonMinDate)) 280 && (lessonMaxDate == null || dateSeance.isBefore(lessonMaxDate))) 281 { 282 // all cancelled lessons should have the same course compute it once 283 if (cours == null) 284 { 285 Integer cleCours = seance.getCleCours(); 286 if (cleCours != 0) 287 { 288 cours = _coursApi.coursCleGet(cleCours.toString()); 289 } 290 else 291 { 292 // no course links to the cancelled course. Ignore 293 if (getLogger().isDebugEnabled()) 294 { 295 getLogger().debug("La séance annulée suivante a une clé de cours égale à 0 et sera ignoré :\n" + seance.toString()); 296 } 297 break; 298 } 299 } 300 301 List<String> casIdentifiers = _getCASIdentifiers(cours.getEtudiants()); 302 CancelledLesson cancelledLesson = new CancelledLesson( 303 matiere.getCode(), 304 matiere.getLibelle(), 305 matiere.getLibelleLong(), 306 dateSeance, 307 coursAnnule.getMotifAnnulation(), 308 coursAnnule.getCommentaire(), 309 LocalDateTime.parse(coursAnnule.getDateHeureAnnulation()) 310 ); 311 312 result.put(cancelledLesson, casIdentifiers); 313 } 314 } 315 } 316 } 317 } 318 catch (ApiException e) 319 { 320 getLogger().error("Failed to compute notification of cancelled course", e); 321 } 322 323 return result; 324 } 325 326 private List<String> _getCASIdentifiers(List<Integer> etudiant) 327 { 328 Cache<String, String> cache = _cacheManager.get(__CAS_IDENTIFIER_CACHE); 329 return etudiant.stream() 330 .map(cle -> cache.get(cle.toString(), c -> { 331 // Do not discards every student because of an error 332 try 333 { 334 return _etudiantsApi.etudiantsCleGet(c).getCasIdentifiant(); 335 } 336 catch (ApiException e) 337 { 338 getLogger().error("Failed to fetch CAS identifiant for student key: " + c); 339 return null; 340 } 341 })) 342 .filter(Objects::nonNull) 343 .toList(); 344 } 345 346 private Predicate<CoursAnnules> _getCancellationDateFilter(LocalDateTime cancellationMinDate) 347 { 348 return cancellationMinDate != null 349 ? c -> LocalDateTime.parse(c.getDateHeureAnnulation(), DateTimeFormatter.ISO_DATE_TIME).isAfter(cancellationMinDate) 350 : c -> true; 351 } 352 353 /** 354 * Get the hyperplanning student key corresponding to the user identity 355 * @param userIdentity the user identity 356 * @return the cle or empty if the user doesn't match a hyperplanning student 357 * @throws ApiException if an error occurs 358 */ 359 protected Optional<String> getEtudiantCle(UserIdentity userIdentity) throws ApiException 360 { 361 List<Etudiants> etudiants = _etudiantsApi.etudiantsGet( 362 null, 363 List.of("cle"), /* select */ 364 null, 365 null, 366 null, 367 null, 368 null, 369 null, 370 null, 371 List.of(userIdentity.getLogin()), /* CAS identity */ 372 null, 373 null 374 ); 375 376 if (etudiants.isEmpty()) 377 { 378 getLogger().debug("No matching hyperplanning student for CAS login: " + userIdentity.getLogin()); 379 return Optional.empty(); 380 } 381 else if (etudiants.size() > 1) 382 { 383 getLogger().info("Multiple hyperplanning student for CAS login: " + userIdentity.getLogin()); 384 return Optional.empty(); 385 } 386 387 return Optional.of(etudiants.get(0).getCle().toString()); 388 } 389 390 private void _initializeWebService() 391 { 392 ApiClient client = new ApiClient(); 393 client.setBasePath(_serverUrl + "/hpsw/api/v1"); 394 client.setUsername(_connectionLogin); 395 client.setPassword(_connectionPass); 396 397 _matiereApi = new MatieresApi(client); 398 _coursApi = new CoursApi(client); 399 _coursAnnulesApi = new CoursAnnulesApi(client); 400 _etudiantsApi = new EtudiantsApi(client); 401 } 402 403 private Cache<UserIdentity, List<CancelledLesson>> getCancelledLessonsCache() 404 { 405 return _cacheManager.get(__CANCELLED_LESSONS_CACHE); 406 } 407 408 /** 409 * Represent a cancelled lesson from hyperplanning 410 * @param code the code of the subject this lesson belongs to 411 * @param label the label of the subject this lesson belongs to 412 * @param fullLabel the full label of the subject this lesson belongs to 413 * @param lessonDate the original date of the lesson 414 * @param cancelRationale the cancellation rationale 415 * @param cancelComment the cancellation comment 416 * @param cancelDate the cancellation date 417 */ 418 public record CancelledLesson( 419 String code, 420 String label, 421 String fullLabel, 422 LocalDateTime lessonDate, 423 String cancelRationale, 424 String cancelComment, 425 LocalDateTime cancelDate 426 ) { } 427 428}