EDT: paramétrage extraction info par expressions régulières

This commit is contained in:
Emmanuel Viennet 2023-11-13 15:08:09 +01:00
parent 46288cd52d
commit 5901ba59d7
5 changed files with 330 additions and 113 deletions

View File

@ -29,6 +29,7 @@
Formulaire configuration Module Assiduités Formulaire configuration Module Assiduités
""" """
import datetime import datetime
import re
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import DecimalField, SubmitField, ValidationError 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}") 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): class ConfigAssiduitesForm(FlaskForm):
"Formulaire paramétrage Module Assiduité" "Formulaire paramétrage Module Assiduité"
@ -120,5 +138,45 @@ class ConfigAssiduitesForm(FlaskForm):
validators=[Optional(), check_ics_path], 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: <tt>Matière : \w+ - ([\w\.\s']+)</tt>
""",
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: <tt>.*- ([\w\s]+)$</tt>
""",
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: <tt>Matière : ([A-Z][A-Z0-9]+)</tt>
""",
validators=[Optional(), check_ics_regexp],
)
submit = SubmitField("Valider") submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -36,6 +36,7 @@ import icalendar
from flask import flash, g, url_for from flask import flash, g, url_for
from app import log from app import log
from app.models import FormSemestre, GroupDescr, ModuleImpl, ScoDocSiteConfig from app.models import FormSemestre, GroupDescr, ModuleImpl, ScoDocSiteConfig
from app.scodoc.sco_exceptions import ScoValueError
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -67,6 +68,7 @@ def formsemestre_load_calendar(
return calendar return calendar
# --- Couleurs des évènements emploi du temps
_COLOR_PALETTE = [ _COLOR_PALETTE = [
"#ff6961", "#ff6961",
"#ffb480", "#ffb480",
@ -77,60 +79,46 @@ _COLOR_PALETTE = [
"#9d94ff", "#9d94ff",
"#c780e8", "#c780e8",
] ]
_EVENT_DEFAULT_COLOR = "rgb(214, 233, 248)"
def formsemestre_edt_dict(formsemestre: FormSemestre) -> list[dict]: def formsemestre_edt_dict(formsemestre: FormSemestre) -> list[dict]:
"""EDT complet du semestre, comme une liste de dict serialisable en json. """EDT complet du semestre, comme une liste de dict serialisable en json.
Fonction appellée par l'API /formsemestre/<int:formsemestre_id>/edt Fonction appellée par l'API /formsemestre/<int:formsemestre_id>/edt
TODO: spécifier intervalle de dates start et end 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)
edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre) # Génération des événements pour le calendrier html
group_colors = { events_cal = []
group_name: _COLOR_PALETTE[i % (len(_COLOR_PALETTE) - 1) + 1] for event in events_scodoc:
for i, group_name in enumerate(edt2group) group: GroupDescr | bool = event["group"]
} if group is False:
default_group = formsemestre.get_default_group() group_disp = f"""<div class="group-edt">
edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre) <span title="extraction emploi du temps non configurée">
{scu.EMO_WARNING} non configuré</span>
# Chargement du calendier ics </div>"""
calendar = formsemestre_load_calendar(formsemestre) else:
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 = []
for event in events:
if "DESCRIPTION" in event:
# --- 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 = ( group_disp = (
f"""<div class="group-name">{group.get_nom_with_part(default="promo")}</div>""" f"""<div class="group-name">{group.get_nom_with_part(default="promo")}</div>"""
if group if group
else f"""<div class="group-edt">{edt_group} else f"""<div class="group-edt">{event['edt_group']}
<span title="vérifier noms de groupe ou configuration extraction edt"> <span title="vérifier noms de groupe ou configuration extraction edt">
{scu.EMO_WARNING} non reconnu</span> {scu.EMO_WARNING} non reconnu</span>
</div>""" </div>"""
) )
# --- ModuleImpl modimpl: ModuleImpl | bool = event["modimpl"]
edt_module = extract_event_module(event) if modimpl is False:
modimpl: ModuleImpl = edt2modimpl.get(edt_module, None) mod_disp = f"""<div class="module-edt" title="extraction emploi du temps non configurée">
{scu.EMO_WARNING} non configuré
</div>"""
else:
mod_disp = ( mod_disp = (
f"""<div class="module-edt mod-name" title="{modimpl.module.abbrev or ""}">{ f"""<div class="module-edt mod-name" title="{modimpl.module.abbrev or ""}">{
modimpl.module.code}</div>""" modimpl.module.code}</div>"""
if modimpl if modimpl
else f"""<div class="module-edt mod-etd" title="vérifier code edt module ?">{ else f"""<div class="module-edt mod-etd" title="code module non trouvé dans ScoDoc.
scu.EMO_WARNING} {edt_module}</div>""" Vérifier configuration.">{
scu.EMO_WARNING} {event['edt_module']}</div>"""
) )
# --- Lien saisie abs # --- Lien saisie abs
link_abs = ( link_abs = (
@ -138,9 +126,11 @@ def formsemestre_edt_dict(formsemestre: FormSemestre) -> list[dict]:
url_for("assiduites.signal_assiduites_group", url_for("assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id, formsemestre_id=formsemestre.id,
moduleimpl_id=modimpl.id,
jour = event.decoded("dtstart").isoformat(),
group_ids=group.id, group_ids=group.id,
heure_deb=event["heure_deb"],
heure_fin=event["heure_fin"],
moduleimpl_id=modimpl.id,
jour = event["jour"],
)}">absences</a> )}">absences</a>
</div>""" </div>"""
if modimpl and group if modimpl and group
@ -149,69 +139,188 @@ def formsemestre_edt_dict(formsemestre: FormSemestre) -> list[dict]:
d = { d = {
# Champs utilisés par tui.calendar # Champs utilisés par tui.calendar
"calendarId": "cal1", "calendarId": "cal1",
"title": extract_event_title(event) + group_disp + mod_disp + link_abs, "title": event["title"] + group_disp + mod_disp + link_abs,
"start": event.decoded("dtstart").isoformat(), "start": event["start"],
"end": event.decoded("dtend").isoformat(), "end": event["end"],
"backgroundColor": background_color, "backgroundColor": event["group_bg_color"],
# Infos brutes pour usage API éventuel # Infos brutes pour usage API éventuel
"group_id": group.id if group else None, "group_id": group.id if group else None,
"group_edt_id": edt_group, "group_edt_id": event["edt_group"],
"moduleimpl_id": modimpl.id if modimpl else None, "moduleimpl_id": modimpl.id if modimpl else None,
} }
events_dict.append(d) events_cal.append(d)
return events_dict return events_cal
def extract_event_title(event: icalendar.cal.Event) -> str: def _load_and_convert_ics(formsemestre: FormSemestre) -> list[dict]:
"""Extrait le titre à afficher dans nos calendriers (si on ne retrouve pas le module ScoDoc) "chargement fichier, filtrage et extraction des identifiants."
En effet, le titre présent dans l'ics emploi du temps est souvent complexe et peu parlant. # Chargement du calendier ics
Par exemple, à l'USPN, Hyperplanning nous donne: calendar = formsemestre_load_calendar(formsemestre)
'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n' if not calendar:
""" return []
# TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ? # --- Paramètres d'extraction
if not event.has_key("DESCRIPTION"): 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]
for i, group_name in enumerate(edt2group)
}
default_group = formsemestre.get_default_group()
edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre)
# ---
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 = (
extract_event_data(event, edt_ics_title_field, edt_ics_title_pattern)
if edt_ics_title_pattern
else "non configuré"
)
# --- Group
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
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(),
}
)
return events_sco
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 "-" return "-"
description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8 data = event.decoded(ics_field).decode("utf-8") # assume ics in utf8
# ici on prend le nom du module m = pattern.search(data)
m = re.search(r"Matière : \w+ - ([\w\.\s']+)", description)
if m and len(m.groups()) > 0: if m and len(m.groups()) > 0:
return m.group(1) return m.group(1)
# fallback: full description # fallback: ics field, complete
return description return data
def extract_event_module(event: icalendar.cal.Event) -> str: # def extract_event_title(event: icalendar.cal.Event) -> str:
"""Extrait le code module de l'emplois du temps. # """Extrait le titre à afficher dans nos calendriers.
Chaine vide si ne le trouve pas. # 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 le code 'VRETR113' dans DESCRIPTION # 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' # '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 ? # # 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"): # if not event.has_key("DESCRIPTION"):
return "-" # return "-"
description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8 # description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8
# extraction du code: # # ici on prend le nom du module
m = re.search(r"Matière : ([A-Z][A-Z0-9]+)", description) # m = re.search(r"Matière : \w+ - ([\w\.\s']+)", description)
if m and len(m.groups()) > 0: # if m and len(m.groups()) > 0:
return m.group(1) # return m.group(1)
return "" # # fallback: full description
# return description
def extract_event_group(event: icalendar.cal.Event) -> str: # def extract_event_module(event: icalendar.cal.Event) -> str:
"""Extrait le nom du groupe (TD, ...). "" si pas de match.""" # """Extrait le code module de l'emplois du temps.
# TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ? # Chaine vide si ne le trouve pas.
# Utilise ici le SUMMARY # Par exemple, à l'USPN, Hyperplanning nous donne le code 'VRETR113' dans DESCRIPTION
# qui est de la forme # 'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n'
# 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"): # # TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ?
return "-" # if not event.has_key("DESCRIPTION"):
summary = event.decoded("SUMMARY").decode("utf-8") # assume ics in utf8 # return "-"
# extraction du code: # description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8
m = re.search(r".*- ([\w\s]+)$", summary) # # extraction du code:
if m and len(m.groups()) > 0: # m = re.search(r"Matière : ([A-Z][A-Z0-9]+)", description)
return m.group(1).strip() # if m and len(m.groups()) > 0:
return "" # 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( def formsemestre_retreive_modimpls_from_edt_id(

View File

@ -1,6 +1,19 @@
{% extends "base.j2" %} {% extends "base.j2" %}
{% import 'bootstrap/wtf.html' as wtf %} {% import 'bootstrap/wtf.html' as wtf %}
{% block styles %}
{{super()}}
<style>
div.config-section {
font-weight: bold;
font-size: 18px;
margin-right: -15px;
margin-left: -15px;
}
</style>
{% endblock %}
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">
@ -33,6 +46,25 @@ affectent notamment les comptages d'absences de tous les bulletins des
{{ wtf.form_field(form.edt_ics_path) }} {{ wtf.form_field(form.edt_ics_path) }}
</div> </div>
<div class="config-section">Extraction des identifiants depuis les calendriers</div>
<div class="help">
Indiquer ici comment récupérer les informations (titre, groupe, module)
dans les calendriers publiés par votre logiciel d'emploi du temps.
</div>
<div class="config-edt">
{{ wtf.form_field(form.edt_ics_title_field) }}
{{ wtf.form_field(form.edt_ics_title_regexp) }}
</div>
<div class="config-edt">
{{ wtf.form_field(form.edt_ics_group_field) }}
{{ wtf.form_field(form.edt_ics_group_regexp) }}
</div>
<div class="config-edt">
{{ wtf.form_field(form.edt_ics_mod_field) }}
{{ wtf.form_field(form.edt_ics_mod_regexp) }}
</div>
<div class="form-group"> <div class="form-group">
{{ wtf.form_field(form.submit) }} {{ wtf.form_field(form.submit) }}
{{ wtf.form_field(form.cancel) }} {{ wtf.form_field(form.cancel) }}
@ -41,8 +73,4 @@ affectent notamment les comptages d'absences de tous les bulletins des
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -51,6 +51,13 @@ document.addEventListener('DOMContentLoaded', function() {
return `<strong>${start}</strong> <span>${event.title}</span>`; return `<strong>${start}</strong> <span>${event.title}</span>`;
}, },
}, },
timezone: {
zones: [
{
timezoneName: 'CET', // TODO récupérer timezone serveur
},
],
},
usageStatistics: false, usageStatistics: false,
week: { week: {
dayNames: [ "Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"], dayNames: [ "Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"],

View File

@ -324,6 +324,16 @@ def config_assiduites():
if request.method == "POST" and form.cancel.data: # cancel button if request.method == "POST" and form.cancel.data: # cancel button
return redirect(url_for("scodoc.index")) 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 form.validate_on_submit():
if ScoDocSiteConfig.set("assi_morning_time", form.data["morning_time"]): if ScoDocSiteConfig.set("assi_morning_time", form.data["morning_time"]):
flash("Heure du début de la journée enregistrée") 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") flash("Heure de fin de la journée enregistrée")
if ScoDocSiteConfig.set("assi_tick_time", float(form.data["tick_time"])): if ScoDocSiteConfig.set("assi_tick_time", float(form.data["tick_time"])):
flash("Granularité de la timeline enregistrée") flash("Granularité de la timeline enregistrée")
if ScoDocSiteConfig.set("edt_ics_path", form.data["edt_ics_path"]): # --- Calendriers emploi du temps
flash("Chemin vers les calendriers ics enregistré") 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")) return redirect(url_for("scodoc.configuration"))
if request.method == "GET": if request.method == "GET":
@ -352,7 +365,9 @@ def config_assiduites():
except ValueError: except ValueError:
form.tick_time.data = 15.0 form.tick_time.data = 15.0
ScoDocSiteConfig.set("assi_tick_time", 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( return render_template(
"assiduites/pages/config_assiduites.j2", "assiduites/pages/config_assiduites.j2",