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,15 +79,118 @@ _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)
# 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"""<div class="group-edt">
<span title="extraction emploi du temps non configurée">
{scu.EMO_WARNING} non configuré</span>
</div>"""
else:
group_disp = (
f"""<div class="group-name">{group.get_nom_with_part(default="promo")}</div>"""
if group
else f"""<div class="group-edt">{event['edt_group']}
<span title="vérifier noms de groupe ou configuration extraction edt">
{scu.EMO_WARNING} non reconnu</span>
</div>"""
)
modimpl: ModuleImpl | bool = event["modimpl"]
if modimpl is False:
mod_disp = f"""<div class="module-edt" title="extraction emploi du temps non configurée">
{scu.EMO_WARNING} non configuré
</div>"""
else:
mod_disp = (
f"""<div class="module-edt mod-name" title="{modimpl.module.abbrev or ""}">{
modimpl.module.code}</div>"""
if modimpl
else f"""<div class="module-edt mod-etd" title="code module non trouvé dans ScoDoc.
Vérifier configuration.">{
scu.EMO_WARNING} {event['edt_module']}</div>"""
)
# --- Lien saisie abs
link_abs = (
f"""<div class="module-edt link-abs"><a class="stdlink" href="{
url_for("assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group.id,
heure_deb=event["heure_deb"],
heure_fin=event["heure_fin"],
moduleimpl_id=modimpl.id,
jour = event["jour"],
)}">absences</a>
</div>"""
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) edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre)
group_colors = { group_colors = {
group_name: _COLOR_PALETTE[i % (len(_COLOR_PALETTE) - 1) + 1] 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() default_group = formsemestre.get_default_group()
edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre) 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 = [e for e in calendar.walk() if e.name == "VEVENT"]
events_dict = [] events_sco = []
for event in events: for event in events:
if "DESCRIPTION" in event: 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 # --- Group
edt_group = extract_event_group(event) if edt_ics_group_pattern:
# si pas de groupe dans l'event, prend toute la promo ("tous") edt_group = extract_event_data(
group: GroupDescr = ( event, edt_ics_group_field, edt_ics_group_pattern
edt2group.get(edt_group, None) if edt_group else default_group )
) # si pas de groupe dans l'event, oi si groupe non reconnu, prend toute la promo ("tous")
background_color = ( group: GroupDescr = (
group_colors.get(edt_group, "rgb(214, 233, 248)") edt2group.get(edt_group, default_group)
if group if edt_group
else "lightgrey" else default_group
) )
group_disp = ( group_bg_color = (
f"""<div class="group-name">{group.get_nom_with_part(default="promo")}</div>""" group_colors.get(edt_group, _EVENT_DEFAULT_COLOR)
if group if group
else f"""<div class="group-edt">{edt_group} else "lightgrey"
<span title="vérifier noms de groupe ou configuration extraction edt"> )
{scu.EMO_WARNING} non reconnu</span> else:
</div>""" edt_group = ""
) group = False
group_bg_color = _EVENT_DEFAULT_COLOR
# --- ModuleImpl # --- ModuleImpl
edt_module = extract_event_module(event) if edt_ics_mod_pattern:
modimpl: ModuleImpl = edt2modimpl.get(edt_module, None) edt_module = extract_event_data(
mod_disp = ( event, edt_ics_mod_field, edt_ics_mod_pattern
f"""<div class="module-edt mod-name" title="{modimpl.module.abbrev or ""}">{ )
modimpl.module.code}</div>""" modimpl: ModuleImpl = edt2modimpl.get(edt_module, None)
if modimpl else:
else f"""<div class="module-edt mod-etd" title="vérifier code edt module ?">{ modimpl = False
scu.EMO_WARNING} {edt_module}</div>""" 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 return events_sco
link_abs = (
f"""<div class="module-edt link-abs"><a class="stdlink" href="{
url_for("assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
moduleimpl_id=modimpl.id,
jour = event.decoded("dtstart").isoformat(),
group_ids=group.id,
)}">absences</a>
</div>"""
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
def extract_event_title(event: icalendar.cal.Event) -> str: def extract_event_data(
"""Extrait le titre à afficher dans nos calendriers (si on ne retrouve pas le module ScoDoc) event: icalendar.cal.Event, ics_field: str, pattern: re.Pattern
En effet, le titre présent dans l'ics emploi du temps est souvent complexe et peu parlant. ) -> str:
Par exemple, à l'USPN, Hyperplanning nous donne: """Extrait la chaine (id) de l'évènement."""
'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n' if not event.has_key(ics_field):
"""
# 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 "-" 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",