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