diff --git a/app/forms/formsemestre/edit_modimpls_codes_apo.py b/app/forms/formsemestre/edit_modimpls_codes_apo.py index bdd84a18d7..cccf1978c9 100644 --- a/app/forms/formsemestre/edit_modimpls_codes_apo.py +++ b/app/forms/formsemestre/edit_modimpls_codes_apo.py @@ -4,7 +4,7 @@ Formulaire configuration des codes Apo et EDT des modimps d'un formsemestre from flask_wtf import FlaskForm from wtforms import validators -from wtforms.fields.simple import BooleanField, StringField, SubmitField +from wtforms.fields.simple import StringField, SubmitField from app.models import FormSemestre, ModuleImpl diff --git a/app/forms/main/config_assiduites.py b/app/forms/main/config_assiduites.py index da62b8b4d7..d4adb05713 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/models/formsemestre.py b/app/models/formsemestre.py index 705e262487..b444980071 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -631,7 +631,7 @@ class FormSemestre(db.Model): def can_change_groups(self, user: User = None) -> bool: """Vrai si l'utilisateur (par def. current) peut changer les groupes dans - ce semestre: vérifie permission et verrouillage. + ce semestre: vérifie permission et verrouillage (mais pas si la partition est éditable). """ if not self.etat: return False # semestre verrouillé diff --git a/app/models/groups.py b/app/models/groups.py index 33eac26044..8e8791ca09 100644 --- a/app/models/groups.py +++ b/app/models/groups.py @@ -282,18 +282,18 @@ class GroupDescr(db.Model): return False return True - def set_name( - self, group_name: str, edt_id: str | bool = False, dest_url: str = None - ): + def set_name(self, group_name: str, dest_url: str = None): """Set group name, and optionally edt_id. - Check permission and invalidate caches. Commit session. + Check permission (partition must be groups_editable) + and invalidate caches. Commit session. dest_url is used for error messages. """ if not self.partition.formsemestre.can_change_groups(): raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") if self.group_name is None: raise ValueError("can't set a name to default group") - + if not self.partition.groups_editable: + raise AccessDenied("Partition non éditable") if group_name: group_name = group_name.strip() if not group_name: @@ -306,16 +306,22 @@ class GroupDescr(db.Model): ) self.group_name = group_name - if edt_id is not False: - if isinstance(edt_id, str): - edt_id = edt_id.strip() or None - self.edt_id = edt_id db.session.add(self) db.session.commit() sco_cache.invalidate_formsemestre( formsemestre_id=self.partition.formsemestre_id ) + def set_edt_id(self, edt_id: str): + "Set edt_id. Check permission. Commit session." + if not self.partition.formsemestre.can_change_groups(): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + if isinstance(edt_id, str): + edt_id = edt_id.strip() or None + self.edt_id = edt_id + db.session.add(self) + db.session.commit() + def remove_etud(self, etud: "Identite"): "Enlève l'étudiant de ce groupe s'il en fait partie (ne fait rien sinon)" if etud in self.etuds: diff --git a/app/scodoc/sco_edt_cal.py b/app/scodoc/sco_edt_cal.py index 66d0e5cc73..9fccc0658d 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,45 +198,65 @@ 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(), + } ) +<<<<<<< HEAD # --- Lien saisie abs link_abs = ( f"""