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