diff --git a/app/forms/main/config_assiduites.py b/app/forms/main/config_assiduites.py index 56fd0534f..eec5623e6 100644 --- a/app/forms/main/config_assiduites.py +++ b/app/forms/main/config_assiduites.py @@ -192,15 +192,16 @@ class ConfigAssiduitesForm(FlaskForm): validators=[Optional(), check_ics_regexp], ) edt_ics_uid_field = StringField( - label="Champ contenant l'enseignant", + label="Champ contenant les enseignants", description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""", validators=[Optional(), check_ics_field], ) edt_ics_uid_regexp = StringField( - label="Extraction de l'enseignant", - description=r"""expression régulière python dont le premier groupe doit - correspondre à l'identifiant (edt_id) de l'enseignant associé à l'évènement. - Exemple: Enseignant : ([0-9]+) + label="Extraction des enseignants", + description=r"""expression régulière python permettant d'extraire les + identifiants des enseignants associés à l'évènement. + (contrairement aux autres champs, il peut y avoir plusieurs enseignants par évènement.) + Exemple: [0-9]+ """, validators=[Optional(), check_ics_regexp], ) diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 994e201d2..4506eed01 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -280,7 +280,10 @@ class FormSemestre(db.Model): raise ScoValueError("Le semestre n'a pas de groupe par défaut") def get_edt_ids(self) -> list[str]: - "l'ids pour l'emploi du temps: à défaut, les codes étape Apogée" + """Les ids pour l'emploi du temps: à défaut, les codes étape Apogée. + Les edt_id de formsemestres ne sont pas normalisés afin de contrôler + précisément l'accès au fichier ics. + """ return ( scu.split_id(self.edt_id) or [e.etape_apo.strip() for e in self.etapes if e.etape_apo] diff --git a/app/models/groups.py b/app/models/groups.py index 6c112058f..7250f1e67 100644 --- a/app/models/groups.py +++ b/app/models/groups.py @@ -251,8 +251,11 @@ class GroupDescr(ScoDocModel): return d def get_edt_ids(self) -> list[str]: - "les ids pour l'emploi du temps: à défaut, le nom scodoc du groupe" - return scu.split_id(self.edt_id) or [self.group_name] or [] + "les ids normalisés pour l'emploi du temps: à défaut, le nom scodoc du groupe" + return [ + scu.normalize_edt_id(x) + for x in scu.split_id(self.edt_id) or [self.group_name] or [] + ] def get_nb_inscrits(self) -> int: """Nombre inscrits à ce group et au formsemestre. diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index a98fcf0f2..b674ed996 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -67,11 +67,10 @@ class ModuleImpl(db.Model): def get_edt_ids(self) -> list[str]: "les ids pour l'emploi du temps: à défaut, les codes Apogée" - return ( - scu.split_id(self.edt_id) - or scu.split_id(self.code_apogee) - or self.module.get_edt_ids() - ) + return [ + scu.normalize_edt_id(x) + for x in scu.split_id(self.edt_id) or scu.split_id(self.code_apogee) + ] or self.module.get_edt_ids() def get_evaluations_poids(self) -> pd.DataFrame: """Les poids des évaluations vers les UE (accès via cache)""" diff --git a/app/models/modules.py b/app/models/modules.py index 0410395ca..430835a91 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -292,7 +292,10 @@ class Module(db.Model): def get_edt_ids(self) -> list[str]: "les ids pour l'emploi du temps: à défaut, le 1er code Apogée" - return scu.split_id(self.edt_id) or scu.split_id(self.code_apogee) or [] + return [ + scu.normalize_edt_id(x) + for x in scu.split_id(self.edt_id) or scu.split_id(self.code_apogee) or [] + ] def get_parcours(self) -> list[ApcParcours]: """Les parcours utilisant ce module. diff --git a/app/scodoc/sco_edt_cal.py b/app/scodoc/sco_edt_cal.py index 8f3c07152..e182b27f7 100644 --- a/app/scodoc/sco_edt_cal.py +++ b/app/scodoc/sco_edt_cal.py @@ -132,7 +132,8 @@ def load_calendar( ) from exc except FileNotFoundError as exc: log( - f"formsemestre_load_calendar: ics not found for {formsemestre or ''}\npath='{ics_filename}'" + f"""formsemestre_load_calendar: ics not found for { + formsemestre or ''}\npath='{ics_filename}'""" ) raise ScoValueError( f"Fichier ics introuvable (filename={ics_filename})" @@ -229,7 +230,8 @@ def formsemestre_edt_dict( scu.EMO_WARNING} {event['edt_module']}""" bubble = "code module non trouvé dans ScoDoc. Vérifier configuration." case _: # module EDT bien retrouvé dans ScoDoc - bubble = f"{modimpl.module.abbrev or modimpl.module.titre or ''} ({event['edt_module']})" + bubble = f"""{modimpl.module.abbrev or modimpl.module.titre or '' + } ({event['edt_module']})""" mod_disp = ( f"""{modimpl.module.code}""" ) @@ -249,20 +251,24 @@ def formsemestre_edt_dict( else "" ) - ens_user_name = event["ens"].user_name if event["ens"] else None - ens_nomprenom = event["ens"].get_nomprenom() if event["ens"] else None + ens_nomprenoms = ( + "(" + ", ".join([u.get_nomprenom() for u in event["users"]]) + ")" + if event["users"] + else "" + ) + 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_nomprenom+')' if ens_nomprenom else '' - } {link_abs}""", + "title": f"""{title} {group_disp} {ens_nomprenoms} {link_abs}""", "start": event["start"], "end": event["end"], "backgroundColor": event["group_bg_color"], # Infos brutes pour usage API éventuel - "ens_edt": event["edt_ens"], - "ens_user_name": ens_user_name, + "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, @@ -343,7 +349,7 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s 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 tels que dans l'ics + edt_groups_ids = set() # les ids de groupes normalisés tels que dans l'ics default_group = formsemestre.get_default_group() edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre) edt2user: dict[str, User | None] = {} # construit au fur et à mesure (cache) @@ -354,7 +360,7 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s if "DESCRIPTION" in event: # --- Titre de l'évènement title_edt = ( - extract_event_data(event, edt_ics_title_field, edt_ics_title_pattern) + extract_event_edt_id(event, edt_ics_title_field, edt_ics_title_pattern) if edt_ics_title_pattern else "non configuré" ) @@ -362,7 +368,7 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s title = title_edt # --- Group if edt_ics_group_pattern: - edt_group = extract_event_data( + edt_group = extract_event_edt_id( event, edt_ics_group_field, edt_ics_group_pattern ) edt_groups_ids.add(edt_group) @@ -385,7 +391,7 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s # --- ModuleImpl if edt_ics_mod_pattern: - edt_module = extract_event_data( + edt_module = extract_event_edt_id( event, edt_ics_mod_field, edt_ics_mod_pattern ) modimpl: ModuleImpl = edt2modimpl.get(edt_module, None) @@ -394,19 +400,22 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s else: modimpl = False edt_module = "" - # --- Enseignant + # --- Enseignants + users: list[User] = [] if edt_ics_uid_pattern: - edt_ens = extract_event_data( + ens_edt_ids = extract_event_edt_ids( event, edt_ics_uid_field, edt_ics_uid_pattern ) - if edt_ens in edt2user: - ens = edt2user[edt_ens] - else: - ens = User.query.filter_by(edt_id=edt_ens).first() - edt2user[edt_ens] = ens + 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 = None - edt_ens = "" + ens_edt_ids = [] # events_sco.append( { @@ -418,8 +427,8 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s "modimpl": modimpl, # False si extracteur non configuré "edt_module": edt_module, # id module edt non traduit # Enseignant - "edt_ens": edt_ens, # id ens edt, non traduit - "ens": ens, + "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) @@ -437,27 +446,49 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s return events_sco, sorted(edt_groups_ids) -def extract_event_data( +def extract_event_edt_id( event: icalendar.cal.Event, ics_field: str, pattern: re.Pattern, none_if_no_match=False, -) -> str: - """Extrait la chaine (id) de l'évènement.""" +) -> 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 m.group(1) + 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""" + """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(): @@ -469,10 +500,13 @@ def formsemestre_retreive_modimpls_from_edt_id( def formsemestre_retreive_groups_from_edt_id( formsemestre: FormSemestre, ) -> dict[str, GroupDescr]: - """Construit un dict donnant le groupe de chaque edt_id""" + """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 g in partition.groups: - for edt_id in g.get_edt_ids(): - edt2group[edt_id] = g + for group in partition.groups: + for edt_id in group.get_edt_ids(): + edt2group[edt_id] = group return edt2group diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 5e2dae80f..011038c20 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -882,6 +882,18 @@ def suppress_accents(s): return s # may be int +def normalize_edt_id(edt_id: str) -> str: + """Normalize les identifiants edt pour faciliter la correspondance + entre les identifiants ScoDoc et ceux dans l'ics: + Passe tout en majuscules sans accents ni espaces. + """ + return ( + None + if edt_id is None + else suppress_accents(edt_id or "").upper().replace(" ", "") + ) + + class PurgeChars: """delete all chars except those belonging to the specified string""" diff --git a/app/static/css/edt.css b/app/static/css/edt.css index 1564596d1..86ad9f4c0 100644 --- a/app/static/css/edt.css +++ b/app/static/css/edt.css @@ -1,8 +1,67 @@ -#show_modules_titles_form { +#calendar_control_form { display: inline-block; margin-left: 16px; } +/* Style for the dropdown button */ +.dropdown { + position: relative; + display: inline-block; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +.dropbtn { + background-color: rgb(233,233,233); + color: black; + padding: 2px 32px 2px 4px; + margin-left: 16px; + font-size: 16px; + border: 1px solid black; + border-radius: 5px; /* Rounded corners */ + cursor: pointer; + /* Add arrow to the button */ + background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2212%22%20height%3D%227%22%20viewBox%3D%220%200%2012%207%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%3Cpath%20d%3D%22M1%201L6%206L11%201%22%20stroke%3D%22black%22%20stroke-width%3D%222%22/%3E%3C/svg%3E'); + background-repeat: no-repeat; + background-position: right 10px center; +} + +/* Dropdown content (hidden by default) */ +.dropdown-content { + display: none; + position: absolute; + background-color: #f9f9f9; + min-width: 210px; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + z-index: 1; + border-radius: 5px; /* Rounded corners */ + overflow: hidden; /* Ensures rounded corners for dropdown items */ +} + +/* Style for the dropdown items */ +.dropdown-content ul { + list-style: none; + padding: 0; + margin: 0; +} + +.dropdown-content ul li { + color: black; + padding: 12px 16px; + text-decoration: none; + display: block; +} + +.dropdown-content ul li a { + color: black; + text-decoration: none; + display: block; +} + +.dropdown-content ul li label { + cursor: pointer; + display: block; +} + .toastui-calendar-template-time { padding: 4px; word-break: break-all; diff --git a/app/templates/formsemestre/edt.j2 b/app/templates/formsemestre/edt.j2 index 55dba3222..fab72f51b 100644 --- a/app/templates/formsemestre/edt.j2 +++ b/app/templates/formsemestre/edt.j2 @@ -16,11 +16,29 @@ {{ form_groups_choice|safe }} -
- noms complets des modules -
+ +
+ + + +
@@ -35,7 +53,7 @@
-
+