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}