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}