diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index b44498007..6bfbbdbb2 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -269,10 +269,12 @@ class FormSemestre(db.Model): return default_partition.groups.first() raise ScoValueError("Le semestre n'a pas de groupe par défaut") - def get_edt_id(self) -> str: - "l'id pour l'emploi du temps: à défaut, le 1er code étape Apogée" + def get_edt_ids(self) -> list[str]: + "l'ids pour l'emploi du temps: à défaut, les codes étape Apogée" return ( - self.edt_id or "" or (self.etapes[0].etape_apo if len(self.etapes) else "") + scu.split_id(self.edt_id) + or [e.etape_apo.strip() for e in self.etapes if e.etape_apo] + or [] ) def get_infos_dict(self) -> dict: @@ -1040,6 +1042,33 @@ class FormSemestre(db.Model): nb_recorded += 1 return nb_recorded + def change_formation(self, formation_dest: Formation): + """Associe ce formsemestre à une autre formation. + Ce n'est possible que si la formation destination possède des modules de + même code que ceux utilisés dans la formation d'origine du formsemestre. + S'il manque un module, l'opération est annulée. + Commit (or rollback) session. + """ + ok = True + for mi in self.modimpls: + dest_modules = formation_dest.modules.filter_by(code=mi.module.code).all() + match len(dest_modules): + case 1: + mi.module = dest_modules[0] + db.session.add(mi) + case 0: + print(f"Argh ! no module found with code={mi.module.code}") + ok = False + case _: + print(f"Arg ! several modules found with code={mi.module.code}") + ok = False + + if ok: + self.formation_id = formation_dest.id + db.session.commit() + else: + db.session.rollback() + # Association id des utilisateurs responsables (aka directeurs des etudes) du semestre notes_formsemestre_responsables = db.Table( diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 468888e6a..77eba7da8 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -58,12 +58,12 @@ class ModuleImpl(db.Model): return {x.strip() for x in self.code_apogee.split(",") if x} return self.module.get_codes_apogee() - def get_edt_id(self) -> str: - "l'id pour l'emploi du temps: à défaut, le 1er code Apogée" + def get_edt_ids(self) -> list[str]: + "les ids pour l'emploi du temps: à défaut, les codes Apogée" return ( - self.edt_id - or (self.code_apogee.split(",")[0] if self.code_apogee else "") - or self.module.get_edt_id() + 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: diff --git a/app/models/modules.py b/app/models/modules.py index 5a96de3a9..891a476dc 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -285,13 +285,9 @@ class Module(db.Model): return {x.strip() for x in self.code_apogee.split(",") if x} return set() - def get_edt_id(self) -> str: - "l'id pour l'emploi du temps: à défaut, le 1er code Apogée" - return ( - self.edt_id - or (self.code_apogee.split(",")[0] if self.code_apogee else "") - or "" - ) + 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 [] def get_parcours(self) -> list[ApcParcours]: """Les parcours utilisant ce module. diff --git a/app/scodoc/sco_edit_formation.py b/app/scodoc/sco_edit_formation.py index 2327071ba..0857656df 100644 --- a/app/scodoc/sco_edit_formation.py +++ b/app/scodoc/sco_edit_formation.py @@ -80,7 +80,7 @@ def formation_delete(formation_id=None, dialog_confirmed=False): f"""

Confirmer la suppression de la formation {formation.titre} ({formation.acronyme}) ?

-

Attention: la suppression d'une formation est irréversible +

Attention: la suppression d'une formation est irréversible et implique la supression de toutes les UE, matières et modules de la formation !

""", @@ -273,7 +273,8 @@ def formation_edit(formation_id=None, create=False): "\n".join(H) + tf_error_message( f"""Valeurs incorrectes: il existe déjà une formation avec même titre, acronyme et version. """ @@ -285,11 +286,11 @@ def formation_edit(formation_id=None, create=False): if create: formation = do_formation_create(tf[2]) else: - do_formation_edit(tf[2]) - flash( - f"""Création de la formation { - formation.titre} ({formation.acronyme}) version {formation.version}""" - ) + if do_formation_edit(tf[2]): + flash( + f"""Modification de la formation { + formation.titre} ({formation.acronyme}) version {formation.version}""" + ) return flask.redirect( url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id @@ -335,8 +336,8 @@ def do_formation_create(args: dict) -> Formation: return formation -def do_formation_edit(args): - "edit a formation" +def do_formation_edit(args) -> bool: + "edit a formation, returns True if modified" # On ne peut jamais supprimer le code formation: if "formation_code" in args and not args["formation_code"]: @@ -350,11 +351,16 @@ def do_formation_edit(args): if "type_parcours" in args: del args["type_parcours"] + modified = False for field in formation.__dict__: if field in args: value = args[field].strip() if isinstance(args[field], str) else args[field] - if field and field[0] != "_": + if field and field[0] != "_" and getattr(formation, field, None) != value: setattr(formation, field, value) + modified = True + + if not modified: + return False db.session.add(formation) try: @@ -370,6 +376,7 @@ def do_formation_edit(args): ), ) from exc formation.invalidate_cached_sems() + return True def module_move(module_id, after=0, redirect=True): diff --git a/app/scodoc/sco_edt_cal.py b/app/scodoc/sco_edt_cal.py index 9bfaebf6c..7aebfce7f 100644 --- a/app/scodoc/sco_edt_cal.py +++ b/app/scodoc/sco_edt_cal.py @@ -34,7 +34,7 @@ from datetime import timezone import re import icalendar -from flask import flash, g, url_for +from flask import g, url_for from app import log from app.models import FormSemestre, GroupDescr, ModuleImpl, ScoDocSiteConfig from app.scodoc.sco_exceptions import ScoValueError @@ -56,12 +56,14 @@ def formsemestre_load_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: + edt_ids = formsemestre.get_edt_ids() + if not edt_ids: raise ScoValueError( "accès aux emplois du temps non configuré pour ce semestre (pas d'edt_id)" ) - ics_filename = get_ics_filename(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]) if ics_filename is None: raise ScoValueError("accès aux emplois du temps non configuré (pas de chemin)") try: @@ -147,40 +149,51 @@ def formsemestre_edt_dict( 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"""
- {scu.EMO_WARNING} non configuré -
""" - else: - mod_disp = ( - f"""
{ - modimpl.module.code}
""" - if modimpl - else f"""
{ - scu.EMO_WARNING} {event['edt_module']}
""" + url_abs = ( + 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"], ) + if modimpl and group + else None + ) + match modimpl: + case False: # EDT non configuré + mod_disp = f"""{scu.EMO_WARNING} non configuré""" + bubble = "extraction emploi du temps non configurée" + case None: # Module edt non trouvé dans ScoDoc + mod_disp = f"""{ + scu.EMO_WARNING} {event['edt_module']}""" + bubble = "code module non trouvé dans ScoDoc. Vérifier configuration." + case _: # module EDT bien retrouvé dans ScoDoc + mod_disp = f"""{ + modimpl.module.code}""" + bubble = f"{modimpl.module.abbrev or ''} ({event['edt_module']})" + + title = f"""
+ {mod_disp} {event['title']} +
+ """ + # --- Lien saisie abs link_abs = ( f"""""" - if modimpl and group + if url_abs else "" ) d = { # Champs utilisés par tui.calendar "calendarId": "cal1", - "title": event["title"] + group_disp + mod_disp + link_abs, + "title": f"""{title} {group_disp} {link_abs}""", "start": event["start"], "end": event["end"], "backgroundColor": event["group_bg_color"], @@ -245,11 +258,13 @@ def _load_and_convert_ics(formsemestre: FormSemestre) -> list[dict]: for event in events: if "DESCRIPTION" in event: # --- Titre de l'évènement - title = ( + title_edt = ( extract_event_data(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 # --- Group if edt_ics_group_pattern: edt_group = extract_event_data( @@ -278,6 +293,8 @@ def _load_and_convert_ics(formsemestre: FormSemestre) -> list[dict]: 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 = "" @@ -285,7 +302,8 @@ def _load_and_convert_ics(formsemestre: FormSemestre) -> list[dict]: # events_sco.append( { - "title": title, + "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 @@ -376,8 +394,11 @@ 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) + edt2modimpl = {} + for modimpl in formsemestre.modimpls: + for edt_id in modimpl.get_edt_ids(): + if edt_id: + edt2modimpl[edt_id] = modimpl return edt2modimpl diff --git a/app/scodoc/sco_formations.py b/app/scodoc/sco_formations.py index dbd864f53..e17b4945a 100644 --- a/app/scodoc/sco_formations.py +++ b/app/scodoc/sco_formations.py @@ -307,7 +307,7 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False): D = sco_xml.xml_to_dicts(f) except Exception as exc: raise ScoFormatError( - """Ce document xml ne correspond pas à un programme exporté par ScoDoc. + """Ce document xml ne correspond pas à un programme exporté par ScoDoc. (élément 'formation' inexistant par exemple).""" ) from exc assert D[0] == "formation" @@ -322,8 +322,13 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False): referentiel_competence_id = _formation_retreive_refcomp(f_dict) f_dict["referentiel_competence_id"] = referentiel_competence_id # find new version number + acronyme_lower = f_dict["acronyme"].lower() if f_dict["acronyme"] else "" + titre_lower = f_dict["titre"].lower() if f_dict["titre"] else "" formations: list[Formation] = Formation.query.filter_by( - acronyme=f_dict["acronyme"], titre=f_dict["titre"], dept_id=f_dict["dept_id"] + dept_id=f_dict["dept_id"] + ).filter( + db.func.lower(Formation.acronyme) == acronyme_lower, + db.func.lower(Formation.titre) == titre_lower, ) if formations.count(): version = max(f.version or 0 for f in formations) @@ -518,6 +523,7 @@ def formation_list_table() -> GenTable: "_titre_link_class": "stdlink", "_titre_id": f"""titre-{acronyme_no_spaces}""", "version": formation.version or 0, + "commentaire": formation.commentaire or "", } # Ajoute les semestres associés à chaque formation: row["formsemestres"] = formation.formsemestres.order_by( @@ -594,10 +600,12 @@ def formation_list_table() -> GenTable: "formation_code", "version", "titre", + "commentaire", "sems_list_txt", ) titles = { "buttons": "", + "commentaire": "Commentaire", "acronyme": "Acro.", "parcours_name": "Type", "titre": "Titre", diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 6e04677dc..1523c8e82 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -764,13 +764,23 @@ FORBIDDEN_CHARS_EXP = re.compile(r"[*\|~\(\)\\]") ALPHANUM_EXP = re.compile(r"^[\w-]+$", re.UNICODE) -def is_valid_code_nip(s): +def is_valid_code_nip(s: str) -> bool: """True si s peut être un code NIP: au moins 6 chiffres décimaux""" if not s: return False return re.match(r"^[0-9]{6,32}$", s) +def split_id(ident: str) -> list[str]: + """ident est une chaine 'X, Y, Z' + Renvoie ['X','Y', 'Z'] + """ + if ident: + ident = ident.strip() + return [x.strip() for x in ident.strip().split(",")] if ident else [] + return [] + + def strnone(s): "convert s to string, '' if s is false" if s: diff --git a/app/static/css/edt.css b/app/static/css/edt.css index f97c91e85..e60408299 100644 --- a/app/static/css/edt.css +++ b/app/static/css/edt.css @@ -6,8 +6,17 @@ font-size: 12pt; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; } +.module-edt { + display: inline; +} +.mod-code { + font-weight: bold; + color: rgb(21, 21, 116); + font-size: 110%; +} .group-name { color: rgb(25, 113, 25); + display: inline; } .group-edt { color: red; diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index da2dfe36d..0f9072c81 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -2319,7 +2319,10 @@ table.formation_list_table td.buttons span.but_placeholder { } .formation_list_table td.titre { - width: 50%; + width: 45%; +} +.formation_list_table td.commentaire { + font-style: italic; } .formation_list_table td.sems_list_txt { diff --git a/sco_version.py b/sco_version.py index 67d9697b6..e8a195ffc 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.56" +SCOVERSION = "9.6.58" SCONAME = "ScoDoc"