# -*- mode: python -*- # -*- coding: utf-8 -*- ############################################################################## # # Gestion scolarite IUT # # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # Emmanuel Viennet emmanuel.viennet@viennet.net # ############################################################################## """Accès aux emplois du temps Lecture et conversion des ics. """ from datetime import timezone import glob import os import re import time import icalendar from flask import g, url_for from app import log from app.auth.models import User from app.models import FormSemestre, GroupDescr, ModuleImpl, ScoDocSiteConfig from app.scodoc.sco_exceptions import ScoValueError import app.scodoc.sco_utils as scu def get_ics_filename(edt_id: str) -> str | None: "Le chemin vers l'ics de cet edt_id" edt_ics_path = ScoDocSiteConfig.get("edt_ics_path") if not edt_ics_path.strip(): return None return edt_ics_path.format(edt_id=edt_id) def get_ics_directory() -> str | None: "Le répertoire contenant les ics: prend le parent de edt_ics_path" edt_ics_path = ScoDocSiteConfig.get("edt_ics_path") if not edt_ics_path.strip(): return None return os.path.split(edt_ics_path)[0] def get_ics_user_edt_filename(edt_id) -> str | None: """Le chemin vers le fichier ics de l'emploi du temps de l'utilisateur, ou None. edt_id est l'edt_id de l'utilisateur """ if not edt_id: return None edt_ics_user_path = ScoDocSiteConfig.get("edt_ics_user_path") if not edt_ics_user_path.strip(): return None return edt_ics_user_path.format(edt_id=edt_id) def list_edt_calendars() -> list[str]: """Liste des chemins complets vers tous les ics des calendriers de semestres""" path = get_ics_directory() return glob.glob(path + "/*.ics") if path else [] def is_edt_configured() -> bool: "True si accès EDT configuré" return bool(ScoDocSiteConfig.get("edt_ics_path")) def formsemestre_load_calendar( formsemestre: FormSemestre = None, edt_id: str = None ) -> tuple[bytes, icalendar.cal.Calendar]: """Load ics data, return raw ics and decoded calendar. Raises ScoValueError if not configured or not available or invalid format. """ edt_ids = [] if edt_id is None: if formsemestre: edt_ids = formsemestre.get_edt_ids() else: edt_ids = [edt_id] if not edt_ids: raise ScoValueError( "accès aux emplois du temps non configuré pour ce semestre (pas d'edt_id)" ) # Ne charge qu'un seul ics pour le semestre, prend uniquement # le premier edt_id ics_filename = get_ics_filename(edt_ids[0]) return load_calendar(ics_filename, formsemestre=formsemestre, edt_id=edt_id) def load_calendar( ics_filename: str, formsemestre: FormSemestre | None = None, edt_id: str = None ) -> tuple[bytes, icalendar.cal.Calendar]: """Load ics from given (full) path. Return raw ics and decoded calendar. formsemestre and edt_id are optionaly used for error messages. May raise ScoValueError. """ if ics_filename is None: raise ScoValueError("accès aux emplois du temps non configuré (pas de chemin)") try: with open(ics_filename, "rb") as file: log(f"Loading edt from {ics_filename}") data = file.read() try: calendar = icalendar.Calendar.from_ical(data) except ValueError as exc: log( f"""formsemestre_load_calendar: error importing ics for { formsemestre or ''}\npath='{ics_filename}'""" ) raise ScoValueError( f"calendrier ics illisible (edt_id={edt_id})" ) from exc except FileNotFoundError as exc: log( f"""formsemestre_load_calendar: ics not found for { formsemestre or ''}\npath='{ics_filename}'""" ) raise ScoValueError( f"Fichier ics introuvable (filename={ics_filename})" ) from exc except PermissionError as exc: log( f"""formsemestre_load_calendar: permission denied for {formsemestre or '' }\npath='{ics_filename}'""" ) raise ScoValueError( f"Fichier ics inaccessible: vérifier permissions (filename={ics_filename})" ) from exc return data, calendar # --- Couleurs des évènements emploi du temps _COLOR_PALETTE = [ "#ff6961", "#ffb480", "#f8f38d", "#42d6a4", "#08cad1", "#59adf6", "#9d94ff", "#c780e8", ] _EVENT_DEFAULT_COLOR = "rgb(214, 233, 248)" def formsemestre_edt_dict( formsemestre: FormSemestre, group_ids: list[int] = None, show_modules_titles=True, ) -> list[dict]: """EDT complet du semestre, comme une liste de dict serialisable en json. Fonction appelée par l'API /formsemestre//edt group_ids indiquer les groupes ScoDoc à afficher (les autres sont filtrés). Les évènements pour lesquels le groupe ScoDoc n'est pas reconnu sont toujours présents. TODO: spécifier intervalle de dates start et end """ t0 = time.time() try: events_scodoc, _ = load_and_convert_ics(formsemestre) except ScoValueError as exc: return exc.args[0] edt_dict = translate_calendar( events_scodoc, group_ids, show_modules_titles=show_modules_titles ) log( f"formsemestre_edt_dict: loaded edt for {formsemestre} in {(time.time()-t0):g}s" ) return edt_dict def translate_calendar( events_scodoc: list[dict], group_ids: list[int] = None, show_modules_titles=True, ) -> list[dict]: """Génération des événements pour le calendrier html""" group_ids_set = set(group_ids) if group_ids else set() promo_icon = f"""promotion""" abs_icon = f"""saisir absences""" events_cal = [] for event in events_scodoc: group: GroupDescr | bool = event["group"] if group is False: group_disp = f"""
{scu.EMO_WARNING} non configuré
""" else: group_disp = ( f"""
{group.get_nom_with_part(default=promo_icon)}
""" if group else f"""
{event['edt_group']} {scu.EMO_WARNING} non reconnu
""" ) if group and group_ids_set and group.id not in group_ids_set: continue # ignore cet évènement modimpl: ModuleImpl | bool = event["modimpl"] params = { "scodoc_dept": g.scodoc_dept, "heure_deb": scu.heure_to_iso8601(event["heure_deb"]), "heure_fin": scu.heure_to_iso8601(event["heure_fin"]), "day": event["jour"], } if group: params["group_ids"] = group.id if modimpl: params["moduleimpl_id"] = modimpl.id elif group: params["formsemestre_id"] = group.partition.formsemestre_id url_abs = ( url_for("assiduites.signal_assiduites_group", **params) if group else None ) match modimpl: case False: # EDT non configuré mod_disp = f"""{scu.EMO_WARNING} non configuré""" bubble = "extraction emploi du temps non configurée" case None: # Module edt non trouvé dans ScoDoc mod_disp = f"""{ scu.EMO_WARNING} {event['edt_module']}""" bubble = "code module non trouvé dans ce semestre ScoDoc. Vérifier configuration." case _: # module EDT bien retrouvé dans ScoDoc bubble = f"""{modimpl.module.abbrev or modimpl.module.titre or '' } ({event['edt_module']})""" mod_disp = ( f"""{modimpl.module.code}""" ) span_title = f" {event['title']}" if show_modules_titles else "" title = f"""
{mod_disp}{span_title}
""" # --- Lien saisie abs link_abs = ( f"""""" if url_abs else "" ) if event["users"]: # enseignants reconnus dans l'evènement EDT ens_nomprenoms = f"""({ ", ".join([u.get_nomprenom() for u in event["users"]]) })""" else: ens_nomprenoms = f"""(ens. ?)""" ens_user_names = ( ",".join([u.user_name for u in event["users"]]) if event["users"] else "" ) d = { # Champs utilisés par tui.calendar "calendarId": "cal1", "title": f"""{title} {group_disp} {ens_nomprenoms} {link_abs}""", "start": event["start"], "end": event["end"], "backgroundColor": event["group_bg_color"], "raw": event["raw"], # Infos brutes pour usage API éventuel "edt_ens_ids": event["edt_ens_ids"], "ens_user_names": ens_user_names, "group_id": group.id if group else None, "group_edt_id": event["edt_group"], "moduleimpl_id": modimpl.id if modimpl else None, "UID": event["UID"], # icalendar event UID } events_cal.append(d) return events_cal def get_ics_uid_pattern() -> re.Pattern: """L'expression régulière compilée pour extraire l'identifiant de l'enseignant. May raise ScoValueError. """ edt_ics_uid_regexp = ScoDocSiteConfig.get("edt_ics_uid_regexp") try: edt_ics_uid_pattern = ( re.compile(edt_ics_uid_regexp) if edt_ics_uid_regexp else None ) except re.error as exc: raise ScoValueError( "expression d'extraction de l'enseignant depuis l'emploi du temps invalide" ) from exc return edt_ics_uid_pattern def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[str]]: """Chargement fichier ics. Renvoie une liste d'évènements, et la liste des identifiants de groupes trouvés (utilisée pour l'aide). """ # Chargement du calendier ics _, calendar = formsemestre_load_calendar(formsemestre) if not calendar: return [], [] # --- Correspondances id edt -> id scodoc pour groupes edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre) default_group = formsemestre.get_default_group() # --- Correspondances id edt -> id scodoc pour modimpls edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre) return convert_ics( calendar, edt2group=edt2group, default_group=default_group, edt2modimpl=edt2modimpl, ) def convert_ics( calendar: icalendar.cal.Calendar, edt2group: dict[str, GroupDescr] = None, default_group: GroupDescr = None, edt2modimpl: dict[str, ModuleImpl] = None, ) -> tuple[list[dict], list[str]]: """Filtrage et extraction des identifiants des évènements calendrier. Renvoie une liste d'évènements, et la liste des identifiants de groupes trouvés (utilisée pour l'aide). Groupes: - False si extraction regexp non configuré - "tous" (promo) si pas de correspondance trouvée. """ # --- Paramètres d'extraction edt_ics_title_field = ScoDocSiteConfig.get("edt_ics_title_field") edt_ics_title_regexp = ScoDocSiteConfig.get("edt_ics_title_regexp") try: edt_ics_title_pattern = ( re.compile(edt_ics_title_regexp) if edt_ics_title_regexp else None ) except re.error as exc: raise ScoValueError( "expression d'extraction du titre depuis l'emploi du temps invalide" ) from exc edt_ics_group_field = ScoDocSiteConfig.get("edt_ics_group_field") edt_ics_group_regexp = ScoDocSiteConfig.get("edt_ics_group_regexp") try: edt_ics_group_pattern = ( re.compile(edt_ics_group_regexp) if edt_ics_group_regexp else None ) except re.error as exc: raise ScoValueError( "expression d'extraction du groupe depuis l'emploi du temps invalide" ) from exc edt_ics_mod_field = ScoDocSiteConfig.get("edt_ics_mod_field") edt_ics_mod_regexp = ScoDocSiteConfig.get("edt_ics_mod_regexp") try: edt_ics_mod_pattern = ( re.compile(edt_ics_mod_regexp) if edt_ics_mod_regexp else None ) except re.error as exc: raise ScoValueError( "expression d'extraction du module depuis l'emploi du temps invalide" ) from exc edt_ics_uid_field = ScoDocSiteConfig.get("edt_ics_uid_field") edt_ics_uid_pattern = get_ics_uid_pattern() # --- Groupes group_colors = { group_name: _COLOR_PALETTE[i % (len(_COLOR_PALETTE) - 1) + 1] for i, group_name in enumerate(edt2group) } edt_groups_ids = set() # les ids de groupes normalisés tels que dans l'ics edt2user: dict[str, User | None] = {} # construit au fur et à mesure (cache) # --- events = [e for e in calendar.walk() if e.name == "VEVENT"] events_sco = [] for event in events: if "DESCRIPTION" in event: # --- Titre de l'évènement title_edt = ( extract_event_edt_id(event, edt_ics_title_field, edt_ics_title_pattern) if edt_ics_title_pattern else "non configuré" ) # title remplacé par le nom du module scodoc quand il est trouvé title = title_edt # --- ModuleImpl if edt_ics_mod_pattern: edt_module = extract_event_edt_id( event, edt_ics_mod_field, edt_ics_mod_pattern ) modimpl: ModuleImpl = edt2modimpl.get(edt_module, None) if modimpl: title = modimpl.module.titre_str() else: modimpl = False edt_module = "" # --- Group if edt_ics_group_pattern: edt_group = extract_event_edt_id( event, edt_ics_group_field, edt_ics_group_pattern ) edt_groups_ids.add(edt_group) # si pas de groupe dans l'event, ou si groupe non reconnu, # prend toute la promo ("tous") event_default_group = ( default_group if default_group else (modimpl.formsemestre.get_default_group() if modimpl else None) ) group: GroupDescr = ( edt2group.get(edt_group, event_default_group) if edt_group else event_default_group ) group_bg_color = ( group_colors.get(edt_group, _EVENT_DEFAULT_COLOR) if group else "lightgrey" ) else: edt_group = "" group = False group_bg_color = _EVENT_DEFAULT_COLOR # --- Enseignants users: list[User] = [] if edt_ics_uid_pattern: ens_edt_ids = extract_event_edt_ids( event, edt_ics_uid_field, edt_ics_uid_pattern ) for ens_edt_id in ens_edt_ids: if ens_edt_id in edt2user: ens = edt2user[ens_edt_id] else: ens = User.query.filter_by(edt_id=ens_edt_id).first() edt2user[ens_edt_id] = ens if ens: users.append(ens) else: ens_edt_ids = [] # events_sco.append( { "title": title, # titre event ou nom module "title_edt": title_edt, # titre event "edt_group": edt_group, # id group edt non traduit "group": group, # False si extracteur non configuré "group_bg_color": group_bg_color, # associée au groupe "modimpl": modimpl, # False si extracteur non configuré "edt_module": edt_module, # id module edt non traduit # Enseignant "edt_ens_ids": ens_edt_ids, # ids ens edt, normalisés mais non traduits "users": users, # heures pour saisie abs: en heure LOCALE DU SERVEUR "heure_deb": event.decoded("dtstart") .replace(tzinfo=timezone.utc) .astimezone(tz=None) .strftime(scu.TIME_FMT), "heure_fin": event.decoded("dtend") .replace(tzinfo=timezone.utc) .astimezone(tz=None) .strftime(scu.TIME_FMT), "jour": event.decoded("dtstart").date().isoformat(), "start": event.decoded("dtstart").isoformat(), "end": event.decoded("dtend").isoformat(), "UID": event.decoded("UID").decode("utf-8"), "raw": event.to_ical().decode("utf-8"), } ) return events_sco, sorted(edt_groups_ids) def extract_event_edt_id( event: icalendar.cal.Event, ics_field: str, pattern: re.Pattern, none_if_no_match=False, ) -> str | None: """Extrait la chaine (id) de l'évènement et la normalise. Si l'event n'a pas le champ: "-" Si pas de match: None """ if not event.has_key(ics_field): return "-" data = event.decoded(ics_field).decode("utf-8") # assume ics in utf8 m = pattern.search(data) if m and len(m.groups()) > 0: return scu.normalize_edt_id(m.group(1)) # fallback: if not none_if_no_match, ics field complete return None if none_if_no_match else data def extract_event_edt_ids( event: icalendar.cal.Event, ics_field: str, pattern: re.Pattern, ) -> list[str] | None: """Extrait les edt_id de l'évènement et les normalise. Si l'event n'a pas le champ: None Si pas de match: liste vide Utilisé pour les enseignants uniquement. """ if not event.has_key(ics_field): return data = event.decoded(ics_field).decode("utf-8") # assume ics in utf8 matches = pattern.findall(data) # nota: pattern may have zero or one group, so the result # is a list of strings, not a list of matches return [scu.normalize_edt_id(m) for m in matches if m] def formsemestre_retreive_modimpls_from_edt_id( formsemestre: FormSemestre, ) -> dict[str, ModuleImpl]: """Construit un dict donnant le moduleimpl de chaque edt_id (normalisé)""" edt2modimpl = {} for modimpl in formsemestre.modimpls: for edt_id in modimpl.get_edt_ids(): if edt_id: edt2modimpl[edt_id] = modimpl return edt2modimpl def formsemestre_retreive_groups_from_edt_id( formsemestre: FormSemestre, ) -> dict[str, GroupDescr]: """Construit un dict donnant le groupe de chaque edt_id La clé edt_id est sans accents, lowercase. """ edt2group = {} group: GroupDescr for partition in formsemestre.partitions: for group in partition.groups: for edt_id in group.get_edt_ids(): edt2group[edt_id] = group return edt2group