forked from ScoDoc/ScoDoc
565 lines
21 KiB
Python
565 lines
21 KiB
Python
# -*- mode: python -*-
|
|
# -*- coding: utf-8 -*-
|
|
|
|
##############################################################################
|
|
#
|
|
# 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
|
|
|
|
Lecture et conversion des ics.
|
|
|
|
"""
|
|
|
|
from datetime import timezone
|
|
import glob
|
|
import os
|
|
import re
|
|
import time
|
|
import icalendar
|
|
|
|
from flask import g, url_for
|
|
from app import log
|
|
from app.auth.models import User
|
|
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 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"))
|
|
|
|
|
|
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.
|
|
"""
|
|
edt_ids = []
|
|
if edt_id is None:
|
|
if formsemestre:
|
|
edt_ids = formsemestre.get_edt_ids()
|
|
else:
|
|
edt_ids = [edt_id]
|
|
if not edt_ids:
|
|
raise ScoValueError(
|
|
"accès aux emplois du temps non configuré pour ce semestre (pas d'edt_id)"
|
|
)
|
|
# 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:
|
|
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,
|
|
show_modules_titles=True,
|
|
) -> 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
|
|
"""
|
|
t0 = time.time()
|
|
try:
|
|
events_scodoc, _ = load_and_convert_ics(formsemestre)
|
|
except ScoValueError as exc:
|
|
return exc.args[0]
|
|
edt_dict = translate_calendar(
|
|
events_scodoc, group_ids, show_modules_titles=show_modules_titles
|
|
)
|
|
log(
|
|
f"formsemestre_edt_dict: loaded edt for {formsemestre} in {(time.time()-t0):g}s"
|
|
)
|
|
return edt_dict
|
|
|
|
|
|
def translate_calendar(
|
|
events_scodoc: list[dict],
|
|
group_ids: list[int] = None,
|
|
show_modules_titles=True,
|
|
) -> list[dict]:
|
|
"""Génération des événements pour le calendrier html"""
|
|
group_ids_set = set(group_ids) if group_ids else set()
|
|
promo_icon = f"""<img height="18px" src="{scu.STATIC_DIR}/icons/promo.svg"
|
|
title="promotion complète" alt="promotion"/>"""
|
|
abs_icon = f"""<img height="28px" src="{scu.STATIC_DIR}/icons/absences.svg"
|
|
title="saisir absences" alt="saisir absences"/>"""
|
|
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_icon)}</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"]
|
|
params = {
|
|
"scodoc_dept": g.scodoc_dept,
|
|
"group_ids": group.id,
|
|
"heure_deb": event["heure_deb"].replace("h", ":") if "h" in event["heure_deb"] else event["heure_deb"],
|
|
"heure_fin": event["heure_fin"].replace("h", ":") if "h" in event["heure_fin"] else event["heure_fin"],
|
|
"day": event["jour"]
|
|
}
|
|
if modimpl:
|
|
params["moduleimpl_id"] = modimpl.id
|
|
elif group:
|
|
params["formsemestre_id"] = group.partition.formsemestre_id
|
|
|
|
url_abs = (
|
|
url_for("assiduites.signal_assiduites_group", **params)
|
|
if group
|
|
else None
|
|
)
|
|
match modimpl:
|
|
case False: # EDT non configuré
|
|
mod_disp = f"""<span>{scu.EMO_WARNING} non configuré</span>"""
|
|
bubble = "extraction emploi du temps non configurée"
|
|
case None: # Module edt non trouvé dans ScoDoc
|
|
mod_disp = f"""<span class="mod-etd">{
|
|
scu.EMO_WARNING} {event['edt_module']}</span>"""
|
|
bubble = "code module non trouvé dans ce semestre ScoDoc. Vérifier configuration."
|
|
case _: # module EDT bien retrouvé dans ScoDoc
|
|
bubble = f"""{modimpl.module.abbrev or modimpl.module.titre or ''
|
|
} ({event['edt_module']})"""
|
|
mod_disp = (
|
|
f"""<span class="mod-name mod-code">{modimpl.module.code}</span>"""
|
|
)
|
|
span_title = f" <span>{event['title']}</span>" if show_modules_titles else ""
|
|
title = f"""<div class = "module-edt" title="{bubble}">
|
|
<a class="discretelink" href="{url_abs or ''}">{mod_disp}{span_title}</a>
|
|
</div>
|
|
"""
|
|
|
|
# --- Lien saisie abs
|
|
link_abs = (
|
|
f"""<div class="module-edt link-abs"><a class="stdlink" href="{
|
|
url_abs}">{abs_icon}</a>
|
|
</div>"""
|
|
if url_abs
|
|
else ""
|
|
)
|
|
|
|
if event["users"]:
|
|
# enseignants reconnus dans l'evènement EDT
|
|
ens_nomprenoms = f"""<span class="edt-ens">({
|
|
", ".join([u.get_nomprenom() for u in event["users"]])
|
|
})</span>"""
|
|
else:
|
|
ens_nomprenoms = f"""<span class="ens-non-reconnu"
|
|
title="enseignants edt: {', '.join(event['edt_ens_ids'])
|
|
if event["edt_ens_ids"] else '?'
|
|
}">(ens. ?)</span>"""
|
|
|
|
ens_user_names = (
|
|
",".join([u.user_name for u in event["users"]]) if event["users"] else ""
|
|
)
|
|
d = {
|
|
# Champs utilisés par tui.calendar
|
|
"calendarId": "cal1",
|
|
"title": f"""{title} {group_disp} {ens_nomprenoms} {link_abs}""",
|
|
"start": event["start"],
|
|
"end": event["end"],
|
|
"backgroundColor": event["group_bg_color"],
|
|
"raw": event["raw"],
|
|
# Infos brutes pour usage API éventuel
|
|
"edt_ens_ids": event["edt_ens_ids"],
|
|
"ens_user_names": ens_user_names,
|
|
"group_id": group.id if group else None,
|
|
"group_edt_id": event["edt_group"],
|
|
"moduleimpl_id": modimpl.id if modimpl else None,
|
|
"UID": event["UID"], # icalendar event UID
|
|
}
|
|
events_cal.append(d)
|
|
|
|
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.
|
|
Renvoie une liste d'évènements, et la liste des identifiants de groupes
|
|
trouvés (utilisée pour l'aide).
|
|
"""
|
|
# Chargement du calendier ics
|
|
_, calendar = formsemestre_load_calendar(formsemestre)
|
|
if not calendar:
|
|
return [], []
|
|
# --- Correspondances id edt -> id scodoc pour groupes
|
|
edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre)
|
|
default_group = formsemestre.get_default_group()
|
|
# --- Correspondances id edt -> id scodoc pour modimpls
|
|
edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre)
|
|
return convert_ics(
|
|
calendar,
|
|
edt2group=edt2group,
|
|
default_group=default_group,
|
|
edt2modimpl=edt2modimpl,
|
|
)
|
|
|
|
|
|
def convert_ics(
|
|
calendar: icalendar.cal.Calendar,
|
|
edt2group: dict[str, GroupDescr] = None,
|
|
default_group: GroupDescr = None,
|
|
edt2modimpl: dict[str, ModuleImpl] = None,
|
|
) -> tuple[list[dict], list[str]]:
|
|
"""Filtrage et extraction des identifiants des évènements calendrier.
|
|
|
|
Renvoie une liste d'évènements, et la liste des identifiants de groupes
|
|
trouvés (utilisée pour l'aide).
|
|
|
|
Groupes:
|
|
- False si extraction regexp non configuré
|
|
- "tous" (promo) si pas de correspondance trouvée.
|
|
"""
|
|
# --- 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
|
|
edt_ics_uid_field = ScoDocSiteConfig.get("edt_ics_uid_field")
|
|
edt_ics_uid_pattern = get_ics_uid_pattern()
|
|
|
|
# --- Groupes
|
|
group_colors = {
|
|
group_name: _COLOR_PALETTE[i % (len(_COLOR_PALETTE) - 1) + 1]
|
|
for i, group_name in enumerate(edt2group)
|
|
}
|
|
edt_groups_ids = set() # les ids de groupes normalisés tels que dans l'ics
|
|
|
|
edt2user: dict[str, User | None] = {} # construit au fur et à mesure (cache)
|
|
# ---
|
|
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_edt = (
|
|
extract_event_edt_id(event, edt_ics_title_field, edt_ics_title_pattern)
|
|
if edt_ics_title_pattern
|
|
else "non configuré"
|
|
)
|
|
# title remplacé par le nom du module scodoc quand il est trouvé
|
|
title = title_edt
|
|
# --- ModuleImpl
|
|
if edt_ics_mod_pattern:
|
|
edt_module = extract_event_edt_id(
|
|
event, edt_ics_mod_field, edt_ics_mod_pattern
|
|
)
|
|
modimpl: ModuleImpl = edt2modimpl.get(edt_module, None)
|
|
if modimpl:
|
|
title = modimpl.module.titre_str()
|
|
else:
|
|
modimpl = False
|
|
edt_module = ""
|
|
# --- Group
|
|
if edt_ics_group_pattern:
|
|
edt_group = extract_event_edt_id(
|
|
event, edt_ics_group_field, edt_ics_group_pattern
|
|
)
|
|
edt_groups_ids.add(edt_group)
|
|
# si pas de groupe dans l'event, ou si groupe non reconnu,
|
|
# prend toute la promo ("tous")
|
|
event_default_group = (
|
|
default_group
|
|
if default_group
|
|
else (modimpl.formsemestre.get_default_group() if modimpl else None)
|
|
)
|
|
group: GroupDescr = (
|
|
edt2group.get(edt_group, event_default_group)
|
|
if edt_group
|
|
else event_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
|
|
|
|
# --- Enseignants
|
|
users: list[User] = []
|
|
if edt_ics_uid_pattern:
|
|
ens_edt_ids = extract_event_edt_ids(
|
|
event, edt_ics_uid_field, edt_ics_uid_pattern
|
|
)
|
|
for ens_edt_id in ens_edt_ids:
|
|
if ens_edt_id in edt2user:
|
|
ens = edt2user[ens_edt_id]
|
|
else:
|
|
ens = User.query.filter_by(edt_id=ens_edt_id).first()
|
|
edt2user[ens_edt_id] = ens
|
|
if ens:
|
|
users.append(ens)
|
|
else:
|
|
ens_edt_ids = []
|
|
#
|
|
events_sco.append(
|
|
{
|
|
"title": title, # titre event ou nom module
|
|
"title_edt": title_edt, # titre event
|
|
"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
|
|
# Enseignant
|
|
"edt_ens_ids": ens_edt_ids, # ids ens edt, normalisés mais non traduits
|
|
"users": users,
|
|
# heures pour saisie abs: en heure LOCALE DU SERVEUR
|
|
"heure_deb": event.decoded("dtstart")
|
|
.replace(tzinfo=timezone.utc)
|
|
.astimezone(tz=None)
|
|
.strftime(scu.TIME_FMT),
|
|
"heure_fin": event.decoded("dtend")
|
|
.replace(tzinfo=timezone.utc)
|
|
.astimezone(tz=None)
|
|
.strftime(scu.TIME_FMT),
|
|
"jour": event.decoded("dtstart").date().isoformat(),
|
|
"start": event.decoded("dtstart").isoformat(),
|
|
"end": event.decoded("dtend").isoformat(),
|
|
"UID": event.decoded("UID").decode("utf-8"),
|
|
"raw": event.to_ical().decode("utf-8"),
|
|
}
|
|
)
|
|
return events_sco, sorted(edt_groups_ids)
|
|
|
|
|
|
def extract_event_edt_id(
|
|
event: icalendar.cal.Event,
|
|
ics_field: str,
|
|
pattern: re.Pattern,
|
|
none_if_no_match=False,
|
|
) -> str | None:
|
|
"""Extrait la chaine (id) de l'évènement et la normalise.
|
|
Si l'event n'a pas le champ: "-"
|
|
Si pas de match: None
|
|
"""
|
|
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 scu.normalize_edt_id(m.group(1))
|
|
# fallback: if not none_if_no_match, ics field complete
|
|
return None if none_if_no_match else data
|
|
|
|
|
|
def extract_event_edt_ids(
|
|
event: icalendar.cal.Event,
|
|
ics_field: str,
|
|
pattern: re.Pattern,
|
|
) -> list[str] | None:
|
|
"""Extrait les edt_id de l'évènement et les normalise.
|
|
Si l'event n'a pas le champ: None
|
|
Si pas de match: liste vide
|
|
Utilisé pour les enseignants uniquement.
|
|
"""
|
|
if not event.has_key(ics_field):
|
|
return
|
|
data = event.decoded(ics_field).decode("utf-8") # assume ics in utf8
|
|
matches = pattern.findall(data)
|
|
# nota: pattern may have zero or one group, so the result
|
|
# is a list of strings, not a list of matches
|
|
return [scu.normalize_edt_id(m) for m in matches if m]
|
|
|
|
|
|
def formsemestre_retreive_modimpls_from_edt_id(
|
|
formsemestre: FormSemestre,
|
|
) -> dict[str, ModuleImpl]:
|
|
"""Construit un dict donnant le moduleimpl de chaque edt_id (normalisé)"""
|
|
edt2modimpl = {}
|
|
for modimpl in formsemestre.modimpls:
|
|
for edt_id in modimpl.get_edt_ids():
|
|
if edt_id:
|
|
edt2modimpl[edt_id] = modimpl
|
|
return edt2modimpl
|
|
|
|
|
|
def formsemestre_retreive_groups_from_edt_id(
|
|
formsemestre: FormSemestre,
|
|
) -> dict[str, GroupDescr]:
|
|
"""Construit un dict donnant le groupe de chaque edt_id
|
|
La clé edt_id est sans accents, lowercase.
|
|
"""
|
|
edt2group = {}
|
|
group: GroupDescr
|
|
for partition in formsemestre.partitions:
|
|
for group in partition.groups:
|
|
for edt_id in group.get_edt_ids():
|
|
edt2group[edt_id] = group
|
|
return edt2group
|