ScoDoc/app/scodoc/sco_edt_cal.py

393 lines
15 KiB
Python

# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 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
XXX usage uniquement experimental pour tests implémentations
"""
from datetime import timezone
import re
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
def get_ics_filename(edt_id: str) -> str | None:
"Le chemin vers l'ics de cet edt_id"
edt_ics_path = ScoDocSiteConfig.get("edt_ics_path")
if not edt_ics_path.strip():
return None
return edt_ics_path.format(edt_id=edt_id)
def formsemestre_load_calendar(
formsemestre: FormSemestre = None, edt_id: str = None
) -> tuple[bytes, icalendar.cal.Calendar]:
"""Load ics data, return raw ics and decoded calendar.
Raises ScoValueError if not configured or not available or invalid format.
"""
if edt_id is None and formsemestre:
edt_id = formsemestre.get_edt_id()
if not edt_id:
raise ScoValueError(
"accès aux emplois du temps non configuré pour ce semestre (pas d'edt_id)"
)
ics_filename = get_ics_filename(edt_id)
if ics_filename is None:
raise ScoValueError("accès aux emplois du temps non configuré (pas de chemin)")
try:
with open(ics_filename, "rb") as file:
log(f"Loading edt from {ics_filename}")
data = file.read()
try:
calendar = icalendar.Calendar.from_ical(data)
except ValueError as exc:
log(
f"""formsemestre_load_calendar: error importing ics for {
formsemestre or ''}\npath='{ics_filename}'"""
)
raise ScoValueError(
f"calendrier ics illisible (edt_id={edt_id})"
) from exc
except FileNotFoundError as exc:
log(
f"formsemestre_load_calendar: ics not found for {formsemestre or ''}\npath='{ics_filename}'"
)
raise ScoValueError(
f"Fichier ics introuvable (filename={ics_filename})"
) from exc
except PermissionError as exc:
log(
f"""formsemestre_load_calendar: permission denied for {formsemestre or ''
}\npath='{ics_filename}'"""
)
raise ScoValueError(
f"Fichier ics inaccessible: vérifier permissions (filename={ics_filename})"
) from exc
return data, calendar
# --- Couleurs des évènements emploi du temps
_COLOR_PALETTE = [
"#ff6961",
"#ffb480",
"#f8f38d",
"#42d6a4",
"#08cad1",
"#59adf6",
"#9d94ff",
"#c780e8",
]
_EVENT_DEFAULT_COLOR = "rgb(214, 233, 248)"
def formsemestre_edt_dict(
formsemestre: FormSemestre, group_ids: list[int] = None
) -> list[dict]:
"""EDT complet du semestre, comme une liste de dict serialisable en json.
Fonction appelée par l'API /formsemestre/<int:formsemestre_id>/edt
group_ids indiquer les groupes ScoDoc à afficher (les autres sont filtrés).
Les évènements pour lesquels le groupe ScoDoc n'est pas reconnu sont
toujours présents.
TODO: spécifier intervalle de dates start et end
"""
group_ids_set = set(group_ids) if group_ids else set()
try:
events_scodoc = _load_and_convert_ics(formsemestre)
except ScoValueError as exc:
return exc.args[0]
# 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>"""
)
if group and group_ids_set and group.id not in group_ids_set:
continue # ignore cet évènement
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)
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, ou 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
# heures pour saisie abs: en heure LOCALE DU SERVEUR
"heure_deb": event.decoded("dtstart")
.replace(tzinfo=timezone.utc)
.astimezone(tz=None)
.strftime("%H:%M"),
"heure_fin": event.decoded("dtend")
.replace(tzinfo=timezone.utc)
.astimezone(tz=None)
.strftime("%H:%M"),
"jour": event.decoded("dtstart").date().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 "-"
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: ics field, complete
return data
# 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_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(
formsemestre: FormSemestre,
) -> dict[str, ModuleImpl]:
"""Construit un dict donnant le moduleimpl de chaque edt_id"""
edt2modimpl = {modimpl.get_edt_id(): modimpl for modimpl in formsemestre.modimpls}
edt2modimpl.pop("", None)
return edt2modimpl
def formsemestre_retreive_groups_from_edt_id(
formsemestre: FormSemestre,
) -> dict[str, GroupDescr]:
"""Construit un dict donnant le groupe de chaque edt_id"""
edt2group = {}
for partition in formsemestre.partitions:
edt2group.update({g.get_edt_id(): g for g in partition.groups})
edt2group.pop("", None)
return edt2group