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