EDT: construction des calendriers enseignants

This commit is contained in:
Emmanuel Viennet 2024-01-02 23:05:08 +01:00
parent 9987a26d9e
commit 85f0323a80
8 changed files with 212 additions and 13 deletions

View File

@ -141,6 +141,16 @@ class ConfigAssiduitesForm(FlaskForm):
Si ce champ n'est pas renseigné, les emplois du temps ne seront pas utilisés.""", Si ce champ n'est pas renseigné, les emplois du temps ne seront pas utilisés.""",
validators=[Optional(), check_ics_path], 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 <tt>{edt_id}</tt> 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( edt_ics_title_field = StringField(
label="Champ contenant le titre", label="Champ contenant le titre",

View File

@ -31,6 +31,8 @@ Lecture et conversion des ics.
""" """
from datetime import timezone from datetime import timezone
import glob
import os
import re import re
import time import time
import icalendar import icalendar
@ -51,6 +53,33 @@ def get_ics_filename(edt_id: str) -> str | None:
return edt_ics_path.format(edt_id=edt_id) 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: def is_edt_configured() -> bool:
"True si accès EDT configuré" "True si accès EDT configuré"
return bool(ScoDocSiteConfig.get("edt_ics_path")) 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 # Ne charge qu'un seul ics pour le semestre, prend uniquement
# le premier edt_id # le premier edt_id
ics_filename = get_ics_filename(edt_ids[0]) 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: if ics_filename is None:
raise ScoValueError("accès aux emplois du temps non configuré (pas de chemin)") raise ScoValueError("accès aux emplois du temps non configuré (pas de chemin)")
try: try:
@ -235,6 +274,22 @@ def formsemestre_edt_dict(
return events_cal 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]]: def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[str]]:
"""Chargement fichier ics, filtrage et extraction des identifiants. """Chargement fichier ics, filtrage et extraction des identifiants.
Renvoie une liste d'évènements, et la liste des identifiants de groupes 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" "expression d'extraction du module depuis l'emploi du temps invalide"
) from exc ) from exc
edt_ics_uid_field = ScoDocSiteConfig.get("edt_ics_uid_field") edt_ics_uid_field = ScoDocSiteConfig.get("edt_ics_uid_field")
edt_ics_uid_regexp = ScoDocSiteConfig.get("edt_ics_uid_regexp") edt_ics_uid_pattern = get_ics_uid_pattern()
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
# --- Correspondances id edt -> id scodoc pour groupes, modules et enseignants # --- Correspondances id edt -> id scodoc pour groupes, modules et enseignants
edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre) edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre)
group_colors = { group_colors = {
@ -390,7 +438,10 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s
def extract_event_data( 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: ) -> str:
"""Extrait la chaine (id) de l'évènement.""" """Extrait la chaine (id) de l'évènement."""
if not event.has_key(ics_field): if not event.has_key(ics_field):
@ -399,8 +450,8 @@ def extract_event_data(
m = pattern.search(data) m = pattern.search(data)
if m and len(m.groups()) > 0: if m and len(m.groups()) > 0:
return m.group(1) return m.group(1)
# fallback: ics field, complete # fallback: if not none_if_no_match, ics field complete
return data return None if none_if_no_match else data
def formsemestre_retreive_modimpls_from_edt_id( def formsemestre_retreive_modimpls_from_edt_id(

View File

@ -110,6 +110,9 @@ c'est à dire à la montre des étudiants.
</textarea> </textarea>
</div> </div>
</div> </div>
<div class="config-edt">
{{ wtf.form_field(form.edt_ics_user_path) }}
</div>
<div class="config-section">Extraction des identifiants depuis les calendriers</div> <div class="config-section">Extraction des identifiants depuis les calendriers</div>
<div class="help"> <div class="help">
Indiquer ici comment récupérer les informations (titre, groupe, module) Indiquer ici comment récupérer les informations (titre, groupe, module)

View File

@ -334,6 +334,7 @@ def config_assiduites():
("edt_ics_mod_regexp", "Expression extraction module"), ("edt_ics_mod_regexp", "Expression extraction module"),
("edt_ics_uid_field", "Champ contenant l'enseignant"), ("edt_ics_uid_field", "Champ contenant l'enseignant"),
("edt_ics_uid_regexp", "Expression extraction de 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(): if form.validate_on_submit():

View File

@ -1,7 +1,7 @@
# -*- mode: python -*- # -*- mode: python -*-
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
SCOVERSION = "9.6.73" SCOVERSION = "9.6.74"
SCONAME = "ScoDoc" SCONAME = "ScoDoc"

View File

@ -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é""" """Supprime les assiduites et/ou les justificatifs de tous les départements ou du département sélectionné"""
tools.downgrade_module(dept, assiduites, justificatifs) 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()

1
tools/edt/README.md Normal file
View File

@ -0,0 +1 @@
# Outils pour manipuler les emplois du temps ical

125
tools/edt/edt_ens.py Normal file
View File

@ -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