diff --git a/app/forms/main/config_assiduites.py b/app/forms/main/config_assiduites.py index 056543045..16b6f1279 100644 --- a/app/forms/main/config_assiduites.py +++ b/app/forms/main/config_assiduites.py @@ -141,6 +141,16 @@ class ConfigAssiduitesForm(FlaskForm): Si ce champ n'est pas renseigné, les emplois du temps ne seront pas utilisés.""", validators=[Optional(), check_ics_path], ) + edt_ics_user_path = StringField( + label="Chemin vers les ics des utilisateurs (enseignants)", + description="""Optionnel. Chemin absolu unix sur le serveur vers le fichier ics donnant l'emploi + du temps d'un enseignant. La balise {edt_id} sera remplacée par l'edt_id du + de l'utilisateur. + Dans certains cas (XXX), ScoDoc peut générer ces fichiers et les écrira suivant + ce chemin (avec edt_id). + """, + validators=[Optional(), check_ics_path], + ) edt_ics_title_field = StringField( label="Champ contenant le titre", diff --git a/app/scodoc/sco_edt_cal.py b/app/scodoc/sco_edt_cal.py index c21ca8620..8f3c07152 100644 --- a/app/scodoc/sco_edt_cal.py +++ b/app/scodoc/sco_edt_cal.py @@ -31,6 +31,8 @@ Lecture et conversion des ics. """ from datetime import timezone +import glob +import os import re import time import icalendar @@ -51,6 +53,33 @@ def get_ics_filename(edt_id: str) -> str | 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")) @@ -75,6 +104,16 @@ def formsemestre_load_calendar( # 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: @@ -235,6 +274,22 @@ def formsemestre_edt_dict( 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, filtrage et extraction des identifiants. Renvoie une liste d'évènements, et la liste des identifiants de groupes @@ -280,15 +335,8 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s "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_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 + edt_ics_uid_pattern = get_ics_uid_pattern() + # --- Correspondances id edt -> id scodoc pour groupes, modules et enseignants edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre) group_colors = { @@ -390,7 +438,10 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s def extract_event_data( - event: icalendar.cal.Event, ics_field: str, pattern: re.Pattern + event: icalendar.cal.Event, + ics_field: str, + pattern: re.Pattern, + none_if_no_match=False, ) -> str: """Extrait la chaine (id) de l'évènement.""" if not event.has_key(ics_field): @@ -399,8 +450,8 @@ def extract_event_data( m = pattern.search(data) if m and len(m.groups()) > 0: return m.group(1) - # fallback: ics field, complete - return data + # fallback: if not none_if_no_match, ics field complete + return None if none_if_no_match else data def formsemestre_retreive_modimpls_from_edt_id( diff --git a/app/templates/assiduites/pages/config_assiduites.j2 b/app/templates/assiduites/pages/config_assiduites.j2 index b5a8045ac..c3b86b8b6 100644 --- a/app/templates/assiduites/pages/config_assiduites.j2 +++ b/app/templates/assiduites/pages/config_assiduites.j2 @@ -110,6 +110,9 @@ c'est à dire à la montre des étudiants. +
+ {{ wtf.form_field(form.edt_ics_user_path) }} +
Extraction des identifiants depuis les calendriers
Indiquer ici comment récupérer les informations (titre, groupe, module) diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 8a8b5319c..619492e7f 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -334,6 +334,7 @@ def config_assiduites(): ("edt_ics_mod_regexp", "Expression extraction module"), ("edt_ics_uid_field", "Champ contenant l'enseignant"), ("edt_ics_uid_regexp", "Expression extraction de l'enseignant"), + ("edt_ics_user_path", "Chemin vers les ics des enseignants"), ) if form.validate_on_submit(): diff --git a/sco_version.py b/sco_version.py index d7fb73ab8..526737817 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.73" +SCOVERSION = "9.6.74" SCONAME = "ScoDoc" diff --git a/scodoc.py b/scodoc.py index e0a0e2f18..4c082082c 100755 --- a/scodoc.py +++ b/scodoc.py @@ -714,3 +714,11 @@ def downgrade_assiduites_module( ): """Supprime les assiduites et/ou les justificatifs de tous les départements ou du département sélectionné""" tools.downgrade_module(dept, assiduites, justificatifs) + + +@app.cli.command() +def generate_ens_calendars(): # generate-ens-calendars + """Génère les calendrier enseignants à partir des ics semestres""" + from tools.edt import edt_ens + + edt_ens.generate_ens_calendars() diff --git a/tools/edt/README.md b/tools/edt/README.md new file mode 100644 index 000000000..f7c50e19b --- /dev/null +++ b/tools/edt/README.md @@ -0,0 +1 @@ +# Outils pour manipuler les emplois du temps ical diff --git a/tools/edt/edt_ens.py b/tools/edt/edt_ens.py new file mode 100644 index 000000000..d80dfc48f --- /dev/null +++ b/tools/edt/edt_ens.py @@ -0,0 +1,125 @@ +############################################################################## +# +# 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 + +Génération des ics par enseignant à partir des ics par formation. +A utiliser quand les edt des enseignants ne sont pas fournis par l'extérieur. + +Utilise la configuration globale pour extraire l'enseignant de chaque évènement. +Les évènements sans enseignant identifiable sont ignorés. +Tous les ics de `edt_ics_path`/*.ics sont lus. +L'edt de chaque enseignant est enregistré dans `edt_ics_user_path`/user_edt_id.ics + +La construction des ics se fait en mémoire, les fichiers sont écrits à la fin. +""" +from collections import defaultdict +import time +import icalendar + +from flask import flash + +from app import log +from app.auth.models import User +from app.models import ScoDocSiteConfig +from app.scodoc import sco_edt_cal +from app.scodoc.sco_exceptions import ScoValueError +import sco_version + + +def _calendar_factory(): + "Create a new calendar" + cal = icalendar.Calendar() + cal.add( + "prodid", f"-//{sco_version.SCONAME} {sco_version.SCOVERSION}//scodoc.org//" + ) + cal.add("version", "2.0") + return cal + + +def generate_ens_calendars(): + """Regénère les calendriers de tous les enseignants""" + t0 = time.time() + edt_ics_uid_field = ScoDocSiteConfig.get("edt_ics_uid_field") + if not edt_ics_uid_field: + log("generate_ens_calendars: no edt_ics_uid_field, aborting") + return + edt_ics_user_path = ScoDocSiteConfig.get("edt_ics_user_path") + if not edt_ics_user_path: + log("generate_ens_calendars: no edt_ics_user_path, aborting") + return + edt_ics_uid_pattern = sco_edt_cal.get_ics_uid_pattern() + edt_by_uid = defaultdict(_calendar_factory) + edt2user: dict[str, User | None] = {} # construit au fur et à mesure (cache) + nb_events = 0 # to log + ics_filenames = sco_edt_cal.list_edt_calendars() + for ics_filename in ics_filenames: + try: + _, calendar = sco_edt_cal.load_calendar(ics_filename) + except ScoValueError: + continue # skip, ignoring errors + if not calendar: + continue + + events = [e for e in calendar.walk() if e.name == "VEVENT"] + nb_events += len(events) + ens: User | None = None + for event in events: + edt_ens = sco_edt_cal.extract_event_data( + event, edt_ics_uid_field, edt_ics_uid_pattern, none_if_no_match=True + ) + if edt_ens in edt2user: + ens = edt2user[edt_ens] + else: + ens = User.query.filter_by(edt_id=edt_ens).first() + edt2user[edt_ens] = ens + if ens: # si l'utilisateur est reconnu + event.add("X-ScoDoc-user", ens.user_name) + edt_by_uid[edt_ens].add_component(event) + _write_user_calendars(edt_by_uid) + log( + f"""generate_ens_calendars: done in {(time.time()-t0):g}s, processed { + nb_events} events from {len(ics_filenames)} calendars, for {len(edt_by_uid)} users""" + ) + + +def _write_user_calendars(edt_by_uid: defaultdict): + """Write all ics files, one per user""" + for edt_id, cal in edt_by_uid.items(): + if not len(cal): # nb: empty cals are True + continue # safeguard, never generate empty cals + filename = sco_edt_cal.get_ics_user_edt_filename(edt_id) + if filename: + log(f"generate_ens_calendars: writing {filename}") + try: + with open(filename, "wb") as f: + f.write(cal.to_ical()) + except PermissionError: + log("_write_user_calendars: permission denied") + flash("Erreur: permission écriture calendrier non accordée") + return # abort + except FileNotFoundError: + log("_write_user_calendars: No such file or directory") + flash("Erreur: chemin incorrect pour écrire le calendrier") + return # abort