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}