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