From 5901ba59d7056e909dcfafab0a8ac5bfbd22cf32 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 13 Nov 2023 15:08:09 +0100 Subject: [PATCH] =?UTF-8?q?EDT:=20param=C3=A9trage=20extraction=20info=20p?= =?UTF-8?q?ar=20expressions=20r=C3=A9guli=C3=A8res?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/forms/main/config_assiduites.py | 58 ++++ app/scodoc/sco_edt_cal.py | 321 ++++++++++++------ .../assiduites/pages/config_assiduites.j2 | 36 +- app/templates/formsemestre/edt.j2 | 7 + app/views/scodoc.py | 21 +- 5 files changed, 330 insertions(+), 113 deletions(-) diff --git a/app/forms/main/config_assiduites.py b/app/forms/main/config_assiduites.py index da62b8b4d..d4adb0571 100644 --- a/app/forms/main/config_assiduites.py +++ b/app/forms/main/config_assiduites.py @@ -29,6 +29,7 @@ Formulaire configuration Module Assiduités """ import datetime +import re from flask_wtf import FlaskForm from wtforms import DecimalField, SubmitField, ValidationError @@ -98,6 +99,23 @@ def check_ics_path(form, field): raise ValidationError("Le chemin vers les ics doit utiliser {edt_id}") +def check_ics_field(form, field): + """Vérifie que c'est un nom de champ crédible: un mot alphanumérique""" + if not re.match(r"^[a-zA-Z\-_0-9]+$", field.data): + raise ValidationError("nom de champ ics invalide") + + +def check_ics_regexp(form, field): + """Vérifie que field est une expresssion régulière""" + value = field.data.strip() + # check that it compiles + try: + _ = re.compile(value) + except re.error as exc: + raise ValidationError("expression invalide") from exc + return True + + class ConfigAssiduitesForm(FlaskForm): "Formulaire paramétrage Module Assiduité" @@ -120,5 +138,45 @@ class ConfigAssiduitesForm(FlaskForm): validators=[Optional(), check_ics_path], ) + edt_ics_title_field = StringField( + label="Champs contenant le titre", + description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""", + validators=[Optional(), check_ics_field], + ) + edt_ics_title_regexp = StringField( + label="Extraction du titre", + description=r"""expression régulière python dont le premier groupe doit + sera le titre de l'évènement affcihé dans le calendrier ScoDoc. + Exemple: Matière : \w+ - ([\w\.\s']+) + """, + validators=[Optional(), check_ics_regexp], + ) + edt_ics_group_field = StringField( + label="Champs contenant le groupe", + description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""", + validators=[Optional(), check_ics_field], + ) + edt_ics_group_regexp = StringField( + label="Extraction du groupe", + description=r"""expression régulière python dont le premier groupe doit + correspondre à l'identifiant de groupe de l'emploi du temps. + Exemple: .*- ([\w\s]+)$ + """, + validators=[Optional(), check_ics_regexp], + ) + edt_ics_mod_field = StringField( + label="Champs contenant le module", + description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""", + validators=[Optional(), check_ics_field], + ) + edt_ics_mod_regexp = StringField( + label="Extraction du module", + description=r"""expression régulière python dont le premier groupe doit + correspondre à l'identifiant (code) du module de l'emploi du temps. + Exemple: Matière : ([A-Z][A-Z0-9]+) + """, + validators=[Optional(), check_ics_regexp], + ) + submit = SubmitField("Valider") cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/scodoc/sco_edt_cal.py b/app/scodoc/sco_edt_cal.py index e8ba3110e..81c525097 100644 --- a/app/scodoc/sco_edt_cal.py +++ b/app/scodoc/sco_edt_cal.py @@ -36,6 +36,7 @@ import icalendar from flask import flash, g, url_for from app import log from app.models import FormSemestre, GroupDescr, ModuleImpl, ScoDocSiteConfig +from app.scodoc.sco_exceptions import ScoValueError import app.scodoc.sco_utils as scu @@ -67,6 +68,7 @@ def formsemestre_load_calendar( return calendar +# --- Couleurs des évènements emploi du temps _COLOR_PALETTE = [ "#ff6961", "#ffb480", @@ -77,15 +79,118 @@ _COLOR_PALETTE = [ "#9d94ff", "#c780e8", ] +_EVENT_DEFAULT_COLOR = "rgb(214, 233, 248)" def formsemestre_edt_dict(formsemestre: FormSemestre) -> list[dict]: """EDT complet du semestre, comme une liste de dict serialisable en json. Fonction appellée par l'API /formsemestre//edt TODO: spécifier intervalle de dates start et end - TODO: cacher ? """ - # Correspondances id edt -> id scodoc pour groupes, modules et enseignants + events_scodoc = _load_and_convert_ics(formsemestre) + # Génération des événements pour le calendrier html + 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")}
""" + if group + else f"""
{event['edt_group']} + + {scu.EMO_WARNING} non reconnu +
""" + ) + modimpl: ModuleImpl | bool = event["modimpl"] + if modimpl is False: + mod_disp = f"""
+ {scu.EMO_WARNING} non configuré +
""" + else: + mod_disp = ( + f"""
{ + modimpl.module.code}
""" + if modimpl + else f"""
{ + scu.EMO_WARNING} {event['edt_module']}
""" + ) + # --- Lien saisie abs + link_abs = ( + f"""""" + if modimpl and group + else "" + ) + d = { + # Champs utilisés par tui.calendar + "calendarId": "cal1", + "title": event["title"] + group_disp + mod_disp + link_abs, + "start": event["start"], + "end": event["end"], + "backgroundColor": event["group_bg_color"], + # Infos brutes pour usage API éventuel + "group_id": group.id if group else None, + "group_edt_id": event["edt_group"], + "moduleimpl_id": modimpl.id if modimpl else None, + } + events_cal.append(d) + + return events_cal + + +def _load_and_convert_ics(formsemestre: FormSemestre) -> list[dict]: + "chargement fichier, filtrage et extraction des identifiants." + # Chargement du calendier ics + calendar = formsemestre_load_calendar(formsemestre) + if not calendar: + return [] + # --- 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 + # --- Correspondances id edt -> id scodoc pour groupes, modules et enseignants edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre) group_colors = { group_name: _COLOR_PALETTE[i % (len(_COLOR_PALETTE) - 1) + 1] @@ -93,125 +198,129 @@ def formsemestre_edt_dict(formsemestre: FormSemestre) -> list[dict]: } default_group = formsemestre.get_default_group() edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre) - - # Chargement du calendier ics - calendar = formsemestre_load_calendar(formsemestre) - if not calendar: - return [] - # Génération des événements, avec titre et champs utiles pour l'affichage dans ScoDoc + # --- events = [e for e in calendar.walk() if e.name == "VEVENT"] - events_dict = [] + events_sco = [] for event in events: if "DESCRIPTION" in event: + # --- Titre de l'évènement + title = ( + extract_event_data(event, edt_ics_title_field, edt_ics_title_pattern) + if edt_ics_title_pattern + else "non configuré" + ) # --- Group - edt_group = extract_event_group(event) - # si pas de groupe dans l'event, prend toute la promo ("tous") - group: GroupDescr = ( - edt2group.get(edt_group, None) if edt_group else default_group - ) - background_color = ( - group_colors.get(edt_group, "rgb(214, 233, 248)") - if group - else "lightgrey" - ) - group_disp = ( - f"""
{group.get_nom_with_part(default="promo")}
""" - if group - else f"""
{edt_group} - - {scu.EMO_WARNING} non reconnu -
""" - ) + if edt_ics_group_pattern: + edt_group = extract_event_data( + event, edt_ics_group_field, edt_ics_group_pattern + ) + # si pas de groupe dans l'event, oi si groupe non reconnu, prend toute la promo ("tous") + group: GroupDescr = ( + edt2group.get(edt_group, default_group) + if edt_group + else 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 + # --- ModuleImpl - edt_module = extract_event_module(event) - modimpl: ModuleImpl = edt2modimpl.get(edt_module, None) - mod_disp = ( - f"""
{ - modimpl.module.code}
""" - if modimpl - else f"""
{ - scu.EMO_WARNING} {edt_module}
""" + if edt_ics_mod_pattern: + edt_module = extract_event_data( + event, edt_ics_mod_field, edt_ics_mod_pattern + ) + modimpl: ModuleImpl = edt2modimpl.get(edt_module, None) + else: + modimpl = False + edt_module = "" + # --- TODO: enseignant + # + events_sco.append( + { + "title": title, + "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 + "heure_deb": event.decoded("dtstart").strftime("%H:%M"), + "heure_fin": event.decoded("dtend").strftime("%H:%M"), + "jour": event.decoded("dtstart").isoformat(), + "start": event.decoded("dtstart").isoformat(), + "end": event.decoded("dtend").isoformat(), + } ) - # --- Lien saisie abs - link_abs = ( - f"""""" - if modimpl and group - else "" - ) - d = { - # Champs utilisés par tui.calendar - "calendarId": "cal1", - "title": extract_event_title(event) + group_disp + mod_disp + link_abs, - "start": event.decoded("dtstart").isoformat(), - "end": event.decoded("dtend").isoformat(), - "backgroundColor": background_color, - # Infos brutes pour usage API éventuel - "group_id": group.id if group else None, - "group_edt_id": edt_group, - "moduleimpl_id": modimpl.id if modimpl else None, - } - events_dict.append(d) - - return events_dict + return events_sco -def extract_event_title(event: icalendar.cal.Event) -> str: - """Extrait le titre à afficher dans nos calendriers (si on ne retrouve pas le module ScoDoc) - En effet, le titre présent dans l'ics emploi du temps est souvent complexe et peu parlant. - Par exemple, à l'USPN, Hyperplanning nous donne: - 'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n' - """ - # TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ? - if not event.has_key("DESCRIPTION"): +def extract_event_data( + event: icalendar.cal.Event, ics_field: str, pattern: re.Pattern +) -> str: + """Extrait la chaine (id) de l'évènement.""" + if not event.has_key(ics_field): return "-" - description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8 - # ici on prend le nom du module - m = re.search(r"Matière : \w+ - ([\w\.\s']+)", description) + data = event.decoded(ics_field).decode("utf-8") # assume ics in utf8 + m = pattern.search(data) if m and len(m.groups()) > 0: return m.group(1) - # fallback: full description - return description + # fallback: ics field, complete + return data -def extract_event_module(event: icalendar.cal.Event) -> str: - """Extrait le code module de l'emplois du temps. - Chaine vide si ne le trouve pas. - Par exemple, à l'USPN, Hyperplanning nous donne le code 'VRETR113' dans DESCRIPTION - 'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n' - """ - # TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ? - if not event.has_key("DESCRIPTION"): - return "-" - description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8 - # extraction du code: - m = re.search(r"Matière : ([A-Z][A-Z0-9]+)", description) - if m and len(m.groups()) > 0: - return m.group(1) - return "" +# def extract_event_title(event: icalendar.cal.Event) -> str: +# """Extrait le titre à afficher dans nos calendriers. +# En effet, le titre présent dans l'ics emploi du temps est souvent complexe et peu parlant. +# Par exemple, à l'USPN, Hyperplanning nous donne: +# 'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n' +# """ +# # TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ? +# if not event.has_key("DESCRIPTION"): +# return "-" +# description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8 +# # ici on prend le nom du module +# m = re.search(r"Matière : \w+ - ([\w\.\s']+)", description) +# if m and len(m.groups()) > 0: +# return m.group(1) +# # fallback: full description +# return description -def extract_event_group(event: icalendar.cal.Event) -> str: - """Extrait le nom du groupe (TD, ...). "" si pas de match.""" - # TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ? - # Utilise ici le SUMMARY - # qui est de la forme - # SUMMARY;LANGUAGE=fr:TP2 GPR1 - VCYR303 - Services reseaux ava (VCYR303) - 1234 - M. VIENNET EMMANUEL - V2ROM - BUT2 RT pa. ROM - Groupe 1 - if not event.has_key("SUMMARY"): - return "-" - summary = event.decoded("SUMMARY").decode("utf-8") # assume ics in utf8 - # extraction du code: - m = re.search(r".*- ([\w\s]+)$", summary) - if m and len(m.groups()) > 0: - return m.group(1).strip() - return "" +# def extract_event_module(event: icalendar.cal.Event) -> str: +# """Extrait le code module de l'emplois du temps. +# Chaine vide si ne le trouve pas. +# Par exemple, à l'USPN, Hyperplanning nous donne le code 'VRETR113' dans DESCRIPTION +# 'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n' +# """ +# # TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ? +# if not event.has_key("DESCRIPTION"): +# return "-" +# description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8 +# # extraction du code: +# m = re.search(r"Matière : ([A-Z][A-Z0-9]+)", description) +# if m and len(m.groups()) > 0: +# return m.group(1) +# return "" + + +# def extract_event_group(event: icalendar.cal.Event) -> str: +# """Extrait le nom du groupe (TD, ...). "" si pas de match.""" +# # Utilise ici le SUMMARY +# # qui est de la forme +# # SUMMARY;LANGUAGE=fr:TP2 GPR1 - VCYR303 - Services reseaux ava (VCYR303) - 1234 - M. VIENNET EMMANUEL - V2ROM - BUT2 RT pa. ROM - Groupe 1 +# if not event.has_key("SUMMARY"): +# return "-" +# summary = event.decoded("SUMMARY").decode("utf-8") # assume ics in utf8 +# # extraction du code: +# m = re.search(r".*- ([\w\s]+)$", summary) +# if m and len(m.groups()) > 0: +# return m.group(1).strip() +# return "" 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 eb6bec567..7522968a9 100644 --- a/app/templates/assiduites/pages/config_assiduites.j2 +++ b/app/templates/assiduites/pages/config_assiduites.j2 @@ -1,6 +1,19 @@ {% extends "base.j2" %} {% import 'bootstrap/wtf.html' as wtf %} +{% block styles %} +{{super()}} + +{% endblock %} + + {% block app_content %}
@@ -33,6 +46,25 @@ affectent notamment les comptages d'absences de tous les bulletins des {{ wtf.form_field(form.edt_ics_path) }}
+
Extraction des identifiants depuis les calendriers
+
+ Indiquer ici comment récupérer les informations (titre, groupe, module) + dans les calendriers publiés par votre logiciel d'emploi du temps. +
+
+ {{ wtf.form_field(form.edt_ics_title_field) }} + {{ wtf.form_field(form.edt_ics_title_regexp) }} +
+
+ {{ wtf.form_field(form.edt_ics_group_field) }} + {{ wtf.form_field(form.edt_ics_group_regexp) }} +
+
+ {{ wtf.form_field(form.edt_ics_mod_field) }} + {{ wtf.form_field(form.edt_ics_mod_regexp) }} +
+ +
{{ wtf.form_field(form.submit) }} {{ wtf.form_field(form.cancel) }} @@ -41,8 +73,4 @@ affectent notamment les comptages d'absences de tous les bulletins des
- - - - {% endblock %} diff --git a/app/templates/formsemestre/edt.j2 b/app/templates/formsemestre/edt.j2 index b6ee3ad90..2dbdaadb1 100644 --- a/app/templates/formsemestre/edt.j2 +++ b/app/templates/formsemestre/edt.j2 @@ -51,6 +51,13 @@ document.addEventListener('DOMContentLoaded', function() { return `${start} ${event.title}`; }, }, + timezone: { + zones: [ + { + timezoneName: 'CET', // TODO récupérer timezone serveur + }, + ], + }, usageStatistics: false, week: { dayNames: [ "Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"], diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 4ecd9f607..4d5af816f 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -324,6 +324,16 @@ def config_assiduites(): if request.method == "POST" and form.cancel.data: # cancel button return redirect(url_for("scodoc.index")) + edt_options = ( + ("edt_ics_path", "Chemin vers les calendriers ics"), + ("edt_ics_title_field", "Champ contenant titre"), + ("edt_ics_title_regexp", "Expression extraction titre"), + ("edt_ics_group_field", "Champ contenant groupe"), + ("edt_ics_group_regexp", "Expression extraction groupe"), + ("edt_ics_mod_field", "Champ contenant module"), + ("edt_ics_mod_regexp", "Expression extraction module"), + ) + if form.validate_on_submit(): if ScoDocSiteConfig.set("assi_morning_time", form.data["morning_time"]): flash("Heure du début de la journée enregistrée") @@ -333,8 +343,11 @@ def config_assiduites(): flash("Heure de fin de la journée enregistrée") if ScoDocSiteConfig.set("assi_tick_time", float(form.data["tick_time"])): flash("Granularité de la timeline enregistrée") - if ScoDocSiteConfig.set("edt_ics_path", form.data["edt_ics_path"]): - flash("Chemin vers les calendriers ics enregistré") + # --- Calendriers emploi du temps + for opt_name, message in edt_options: + if ScoDocSiteConfig.set(opt_name, form.data[opt_name]): + flash(f"{message} enregistré") + return redirect(url_for("scodoc.configuration")) if request.method == "GET": @@ -352,7 +365,9 @@ def config_assiduites(): except ValueError: form.tick_time.data = 15.0 ScoDocSiteConfig.set("assi_tick_time", 15.0) - form.edt_ics_path.data = ScoDocSiteConfig.get("edt_ics_path") + # --- Emplois du temps + for opt_name, _ in edt_options: + getattr(form, opt_name).data = ScoDocSiteConfig.get(opt_name) return render_template( "assiduites/pages/config_assiduites.j2",