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