diff --git a/app/formations/edit_matiere.py b/app/formations/edit_matiere.py index 26add3956c6e5a32ffc16f1dd872335219baf4ad..d758ee1959dc8b884313f09b10cab60f4f38a7bc 100644 --- a/app/formations/edit_matiere.py +++ b/app/formations/edit_matiere.py @@ -25,16 +25,14 @@ # ############################################################################## -"""Ajout/Modification/Supression matieres -(portage from DTML) +"""Ajout/Modification/Suppression matieres """ import flask -from flask import g, render_template, request, url_for +from flask import flash, g, render_template, request, url_for from app import db, log -from app.models import Formation, Matiere, UniteEns, ScolarNews +from app.models import Matiere, UniteEns -import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message @@ -44,59 +42,9 @@ from app.scodoc.sco_exceptions import ( ScoNonEmptyFormationObject, ) -_matiereEditor = ndb.EditableTable( - "notes_matieres", - "matiere_id", - ("matiere_id", "ue_id", "numero", "titre"), - sortkey="numero", - output_formators={"numero": ndb.int_null_is_zero}, -) - - -def matiere_list(*args, **kw): - "list matieres" - cnx = ndb.GetDBConnexion() - return _matiereEditor.list(cnx, *args, **kw) - - -def do_matiere_edit(*args, **kw): - "edit a matiere" - from app.formations import edit_ue - - cnx = ndb.GetDBConnexion() - # check - mat = matiere_list({"matiere_id": args[0]["matiere_id"]})[0] - if matiere_is_locked(mat["matiere_id"]): - raise ScoLockedFormError() - # edit - _matiereEditor.edit(cnx, *args, **kw) - formation_id = edit_ue.ue_list({"ue_id": mat["ue_id"]})[0]["formation_id"] - db.session.get(Formation, formation_id).invalidate_cached_sems() - - -def do_matiere_create(args): - "create a matiere" - from app.formations import edit_ue - - cnx = ndb.GetDBConnexion() - # check - ue = edit_ue.ue_list({"ue_id": args["ue_id"]})[0] - # create matiere - r = _matiereEditor.create(cnx, args) - - # news - formation = db.session.get(Formation, ue["formation_id"]) - ScolarNews.add( - typ=ScolarNews.NEWS_FORM, - obj=ue["formation_id"], - text=f"Modification de la formation {formation.acronyme}", - ) - formation.invalidate_cached_sems() - return r - def matiere_create(ue_id=None): - """Creation d'une matiere""" + """Formulaire création d'une matiere""" ue: UniteEns = UniteEns.query.get_or_404(ue_id) default_numero = max([mat.numero for mat in ue.matieres] or [9]) + 1 H = [ @@ -153,8 +101,8 @@ associé. if tf[0] == -1: return flask.redirect(dest_url) # check unicity - mats = matiere_list(args={"ue_id": ue_id, "titre": tf[2]["titre"]}) - if mats: + nb_mats = Matiere.query.filter_by(ue_id=ue_id, titre=tf[2]["titre"]).count() + if nb_mats: return render_template( "sco_page.j2", title="Création d'une matière", @@ -164,56 +112,14 @@ associé. + tf[1] ), ) - _ = do_matiere_create(tf[2]) + Matiere.create_from_dict(tf[2]) return flask.redirect(dest_url) -def can_delete_matiere(matiere: Matiere) -> tuple[bool, str]: - "True si la matiere n'est pas utilisée dans des formsemestre" - locked = matiere_is_locked(matiere.id) - if locked: - return False - if any(m.modimpls.all() for m in matiere.modules): - return False - return True - - -def do_matiere_delete(oid): - "delete matiere and attached modules" - from app.formations import edit_module, edit_ue - - cnx = ndb.GetDBConnexion() - # check - matiere = Matiere.query.get_or_404(oid) - mat = matiere_list({"matiere_id": oid})[0] # compat sco7 - ue = edit_ue.ue_list({"ue_id": mat["ue_id"]})[0] - if not can_delete_matiere(matiere): - # il y a au moins un modimpl dans un module de cette matière - raise ScoNonEmptyFormationObject("Matière", matiere.titre) - - log(f"do_matiere_delete: matiere_id={matiere.id}") - # delete all modules in this matiere - mods = edit_module.module_list({"matiere_id": matiere.id}) - for mod in mods: - edit_module.do_module_delete(mod["module_id"]) - _matiereEditor.delete(cnx, oid) - - # news - formation = db.session.get(Formation, ue["formation_id"]) - ScolarNews.add( - typ=ScolarNews.NEWS_FORM, - obj=ue["formation_id"], - text=f"Modification de la formation {formation.acronyme}", - ) - formation.invalidate_cached_sems() - - def matiere_delete(matiere_id=None): - """Delete matière""" - from app.formations import edit_ue - - matiere = Matiere.query.get_or_404(matiere_id) - if not can_delete_matiere(matiere): + """Form delete matière""" + matiere = Matiere.get_instance(matiere_id) + if not matiere.can_be_deleted(): # il y a au moins un modimpl dans un module de cette matière raise ScoNonEmptyFormationObject( "Matière", @@ -226,22 +132,20 @@ def matiere_delete(matiere_id=None): ), ) - mat = matiere_list(args={"matiere_id": matiere_id})[0] - ue_dict = edit_ue.ue_list(args={"ue_id": mat["ue_id"]})[0] H = [ - "<h2>Suppression de la matière %(titre)s" % mat, - " dans l'UE (%(acronyme)s))</h2>" % ue_dict, + f"""<h2>Suppression de la matière {matiere.titre} + dans l'UE {matiere.ue.acronyme}</h2>""", ] dest_url = url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, - formation_id=str(ue_dict["formation_id"]), + formation_id=matiere.ue.formation_id, ) tf = TrivialFormulator( request.base_url, scu.get_request_args(), (("matiere_id", {"input_type": "hidden"}),), - initvalues=mat, + initvalues=matiere.to_dict(), submitlabel="Confirmer la suppression", cancelbutton="Annuler", ) @@ -254,29 +158,23 @@ def matiere_delete(matiere_id=None): if tf[0] == -1: return flask.redirect(dest_url) - do_matiere_delete(matiere_id) + matiere.delete() return flask.redirect(dest_url) def matiere_edit(matiere_id=None): - """Edit matiere""" - from app.formations import edit_ue - - F = matiere_list(args={"matiere_id": matiere_id}) - if not F: - raise ScoValueError("Matière inexistante !") - F = F[0] - ues = edit_ue.ue_list(args={"ue_id": F["ue_id"]}) - if not ues: - raise ScoValueError("UE inexistante !") - ue = ues[0] - formation: Formation = Formation.query.get_or_404(ue["formation_id"]) - ues = edit_ue.ue_list(args={"formation_id": ue["formation_id"]}) - ue_names = ["%(acronyme)s (%(titre)s)" % u for u in ues] - ue_ids = [u["ue_id"] for u in ues] + """Form edit matiere""" + matiere: Matiere = Matiere.get_instance(matiere_id) + if matiere.is_locked(): + raise ScoLockedFormError() + ue = matiere.ue + formation = ue.formation + ues = matiere.ue.formation.ues + ue_names = [f"{u.acronyme} ({u.titre or ''})" for u in ues] + ue_ids = [u.id for u in ues] H = [ - """<h2>Modification de la matière %(titre)s""" % F, - f"""(formation ({formation.acronyme}, version {formation.version})</h2>""", + f"""<h2>Modification de la matière {matiere.titre or 'sans titre'} + (formation ({formation.acronyme}, version {formation.version})</h2>""", ] help_msg = """<p class="help">Les matières sont des groupes de modules dans une UE d'une formation donnée. Les matières servent surtout pour la @@ -316,14 +214,14 @@ associé. }, ), ), - initvalues=F, + initvalues=matiere.to_dict(), submitlabel="Modifier les valeurs", ) dest_url = url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, - formation_id=str(ue["formation_id"]), + formation_id=formation.id, ) if tf[0] == 0: return render_template( @@ -335,8 +233,8 @@ associé. return flask.redirect(dest_url) else: # check unicity - mats = matiere_list(args={"ue_id": tf[2]["ue_id"], "titre": tf[2]["titre"]}) - if len(mats) > 1 or (len(mats) == 1 and mats[0]["matiere_id"] != matiere_id): + mats = Matiere.query.filter_by(ue_id=tf[2]["ue_id"], titre=tf[2]["titre"]).all() + if len(mats) > 1 or (len(mats) == 1 and mats[0].id != matiere_id): return render_template( "sco_page.j2", title="Modification d'une matière", @@ -347,32 +245,18 @@ associé. ), ) + modif = False # changement d'UE ? - if tf[2]["ue_id"] != F["ue_id"]: - log("attaching mat %s to new UE %s" % (matiere_id, tf[2]["ue_id"])) - ndb.SimpleQuery( - "UPDATE notes_modules SET ue_id = %(ue_id)s WHERE matiere_id=%(matiere_id)s", - {"ue_id": tf[2]["ue_id"], "matiere_id": matiere_id}, - ) - - do_matiere_edit(tf[2]) - + if tf[2]["ue_id"] != ue.id: + log(f"attaching mat {matiere_id} to new UE id={tf[2]['ue_id']}") + new_ue = UniteEns.get_ue(tf[2]["ue_id"]) + if new_ue.formation_id != formation.id: + raise ScoValueError("UE does not belong to the same formation") + matiere.ue = new_ue + modif = True + modif |= matiere.from_dict(tf[2]) + if modif: + db.session.commit() + matiere.ue.formation.invalidate_cached_sems() + flash("Matière modifiée", "info") return flask.redirect(dest_url) - - -def matiere_is_locked(matiere_id): - """True if matiere should not be modified - (contains modules used in a locked formsemestre) - """ - r = ndb.SimpleDictFetch( - """SELECT ma.id - FROM notes_matieres ma, notes_modules mod, notes_formsemestre sem, notes_moduleimpl mi - WHERE ma.id = mod.matiere_id - AND mi.module_id = mod.id - AND mi.formsemestre_id = sem.id - AND ma.id = %(matiere_id)s - AND sem.etat = false - """, - {"matiere_id": matiere_id}, - ) - return len(r) > 0 diff --git a/app/formations/edit_module.py b/app/formations/edit_module.py index 8cb8dccf67cd71b6558489404f3707fcd5d31e15..71fd3ade7968522b004dcd9e86668d1f0379bc59 100644 --- a/app/formations/edit_module.py +++ b/app/formations/edit_module.py @@ -767,6 +767,7 @@ def module_edit( module_dict["semestre_id"] = 1 else: module_dict["semestre_id"] = module.ue.semestre_idx + tags = module.tags if module else [] tf = TrivialFormulator( request.base_url, scu.get_request_args(), @@ -774,7 +775,9 @@ def module_edit( html_foot_markup=( f"""<div class="scobox sco_tag_module_edit"><span class="sco_tag_edit"><textarea data-module_id="{module_id}" class="module_tag_editor" - >{','.join(sco_tag_module.module_tag_list(module_id))}</textarea></span></div> + >{ + ','.join(t.title for t in tags) + }</textarea></span></div> """ if not create else "" @@ -833,10 +836,14 @@ def module_edit( if matiere: tf[2]["matiere_id"] = matiere.id else: - matiere_id = edit_matiere.do_matiere_create( - {"ue_id": ue.id, "titre": ue.titre or "", "numero": 1}, + matiere = Matiere.create_from_dict( + { + "ue_id": ue.id, + "titre": ue.titre or "", + "numero": 1, + } ) - tf[2]["matiere_id"] = matiere_id + tf[2]["matiere_id"] = matiere.id tf[2]["semestre_id"] = ue.semestre_idx module_id = do_module_create(tf[2]) @@ -946,12 +953,6 @@ def module_is_locked(module_id): return len(r) > 0 -def module_count_moduleimpls(module_id): - "Number of moduleimpls using this module" - mods = sco_moduleimpl.moduleimpl_list(module_id=module_id) - return len(mods) - - def formation_add_malus_modules( formation_id: int, semestre_id: int = None, titre=None, redirect=True ): diff --git a/app/formations/edit_ue.py b/app/formations/edit_ue.py index 3e4d136416c0408edef8bebe9d580fbb3a649dce..dbf2a1dbd925606645db8c3ef2aa20fe574ae155 100644 --- a/app/formations/edit_ue.py +++ b/app/formations/edit_ue.py @@ -547,9 +547,10 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No if is_apc or cursus.UE_IS_MODULE or tf[2]["create_matiere"]: # rappel: en APC, toutes les UE ont une matière, créée ici # (inutilisée mais à laquelle les modules sont rattachés) - matiere_id = edit_matiere.do_matiere_create( - {"ue_id": ue_id, "titre": tf[2]["titre"], "numero": 1}, + matiere = Matiere.create_from_dict( + {"ue_id": ue_id, "titre": tf[2]["titre"], "numero": 1} ) + matiere_id = matiere.id if cursus.UE_IS_MODULE: # dans ce mode, crée un (unique) module dans l'UE: _ = edit_module.do_module_create( @@ -676,9 +677,10 @@ def ue_delete(ue_id=None, delete_validations=False, dialog_confirmed=False): return do_ue_delete(ue, delete_validations=delete_validations) -def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list - """Liste des matières et modules d'une formation, avec liens pour - éditer (si non verrouillée). +def ue_table(formation_id=None, semestre_idx=1, msg=""): + """Page affiochage ou édition d'une formation + avec UEs, matières et module, + et liens pour éditer si non verrouillée et permission. """ from app.scodoc import sco_formsemestre_validation @@ -1240,7 +1242,7 @@ def _ue_table_ues( def _ue_table_matieres( parcours, - ue, + ue_dict: dict, editable, tag_editable, arrow_up, @@ -1250,26 +1252,27 @@ def _ue_table_matieres( delete_disabled_icon, ): """Édition de programme: liste des matières (et leurs modules) d'une UE.""" + ue = UniteEns.get_ue(ue_dict["ue_id"]) H = [] if not parcours.UE_IS_MODULE: H.append('<ul class="notes_matiere_list">') - matieres = edit_matiere.matiere_list(args={"ue_id": ue["ue_id"]}) + matieres = ue.matieres.all() for mat in matieres: if not parcours.UE_IS_MODULE: H.append('<li class="notes_matiere_list">') - if editable and not edit_matiere.matiere_is_locked(mat["matiere_id"]): + if editable and not mat.is_locked(): H.append( f"""<a class="stdlink" href="{ url_for("notes.matiere_edit", - scodoc_dept=g.scodoc_dept, matiere_id=mat["matiere_id"]) + scodoc_dept=g.scodoc_dept, matiere_id=mat.id) }"> """ ) - H.append("%(titre)s" % mat) - if editable and not edit_matiere.matiere_is_locked(mat["matiere_id"]): + H.append(f"{mat.titre or 'sans titre'}") + if editable and not mat.is_locked(): H.append("</a>") - modules = edit_module.module_list(args={"matiere_id": mat["matiere_id"]}) + modules = mat.modules.all() H.append( _ue_table_modules( parcours, @@ -1291,14 +1294,17 @@ def _ue_table_matieres( H.append("<li>Aucune matière dans cette UE ! ") if editable: H.append( - """<a class="stdlink" href="ue_delete?ue_id=%(ue_id)s">supprimer l'UE</a>""" - % ue + f"""<a class="stdlink" href="{ + url_for('notes.ue_delete', scodoc_dept=g.scodoc_dept, ue_id=ue.id) + }">supprimer l'UE</a>""" ) H.append("</li>") if editable and not parcours.UE_IS_MODULE: H.append( - '<li><a class="stdlink" href="matiere_create?ue_id=%(ue_id)s">créer une matière</a> </li>' - % ue + f"""<li><a class="stdlink" href="{ + url_for("notes.matiere_create", scodoc_dept=g.scodoc_dept, ue_id=ue.id) + }">créer une matière</a> + </li>""" ) if not parcours.UE_IS_MODULE: H.append("</ul>") @@ -1307,9 +1313,9 @@ def _ue_table_matieres( def _ue_table_modules( parcours, - ue, - mat, - modules, + ue: UniteEns, + mat: Matiere, + modules: list[Module], editable, tag_editable, arrow_up, @@ -1326,89 +1332,84 @@ def _ue_table_modules( H = ['<ul class="notes_module_list">'] im = 0 for mod in modules: - mod["nb_moduleimpls"] = edit_module.module_count_moduleimpls(mod["module_id"]) + nb_moduleimpls = mod.modimpls.count() klass = "notes_module_list" - if mod["module_type"] == ModuleType.MALUS: + if mod.module_type == ModuleType.MALUS: klass += " module_malus" - H.append('<li class="%s">' % klass) + H.append(f'<li class="{klass}">') H.append('<span class="notes_module_list_buts">') if im != 0 and editable: H.append( - '<a href="module_move?module_id=%s&after=0" class="aud">%s</a>' - % (mod["module_id"], arrow_up) + f"""<a href="module_move?module_id={mod.id}&after=0" class="aud">{arrow_up}</a>""" ) else: H.append(arrow_none) if im < len(modules) - 1 and editable: H.append( - '<a href="module_move?module_id=%s&after=1" class="aud">%s</a>' - % (mod["module_id"], arrow_down) + f"""<a href="module_move?module_id={mod.id}&after=1" class="aud">{arrow_down}</a>""" ) else: H.append(arrow_none) im += 1 - if mod["nb_moduleimpls"] == 0 and editable: - icon = delete_icon - else: - icon = delete_disabled_icon + icon = delete_icon if nb_moduleimpls == 0 and editable else delete_disabled_icon H.append( - '<a class="smallbutton" href="module_delete?module_id=%s">%s</a>' - % (mod["module_id"], icon) + f"""<a class="smallbutton" href="{ + url_for("notes.module_delete", scodoc_dept=g.scodoc_dept, module_id=mod.id) + }">{icon}</a>""" ) - H.append("</span>") - mod_editable = ( - editable # and not edit_module.module_is_locked( Mod['module_id']) - ) + mod_editable = editable + # and not edit_module.module_is_locked(Mod['module_id']) if mod_editable: H.append( - '<a class="discretelink" title="Modifier le module numéro %(numero)s, utilisé par %(nb_moduleimpls)d sessions" href="module_edit?module_id=%(module_id)s">' - % mod + f"""<a class="discretelink" title="Modifier le module numéro %(numero)s, utilisé par + {nb_moduleimpls} sessions" href="{ + url_for("notes.module_edit", scodoc_dept=g.scodoc_dept, module_id=mod.id) + }">""" ) - if mod["module_type"] not in (scu.ModuleType.STANDARD, scu.ModuleType.MALUS): + if mod.module_type not in (scu.ModuleType.STANDARD, scu.ModuleType.MALUS): H.append( f"""<span class="invalid-module-type">{scu.EMO_WARNING} type incompatible </span>""" ) H.append( - '<span class="formation_module_tit">%s</span>' - % scu.join_words(mod["code"], mod["titre"]) + f"""<span class="formation_module_tit">{scu.join_words(mod.code, mod.titre)}</span>""" ) if mod_editable: H.append("</a>") - heurescoef = ( - "%(heures_cours)s/%(heures_td)s/%(heures_tp)s, coef. %(coefficient)s" % mod + heures = ( + f"""{mod.heures_cours or 0}/{mod.heures_td or 0}/{mod.heures_tp or 0}, """ + if (mod.heures_cours or mod.heures_td or mod.heures_tp) + else "" ) + heurescoef = f"""{heures}coef. {mod.coefficient}""" edit_url = url_for( "apiweb.formation_module_set_code_apogee", scodoc_dept=g.scodoc_dept, - module_id=mod["module_id"], + module_id=mod.id, ) heurescoef += f""", Apo: <span class="{'span_apo_edit' if editable else ''}" - data-url="{edit_url}" id="{mod["module_id"]}" + data-url="{edit_url}" id="{mod.id}" data-placeholder="{scu.APO_MISSING_CODE_STR}">{ - mod["code_apogee"] or "" + mod.code_apogee or "" }</span>""" - if tag_editable: - tag_cls = "module_tag_editor" - else: - tag_cls = "module_tag_editor_ro" - tag_mk = """<span class="sco_tag_edit"><form><textarea data-module_id="{}" class="{}">{}</textarea></form></span>""" - tag_edit = tag_mk.format( - mod["module_id"], - tag_cls, - ",".join(sco_tag_module.module_tag_list(mod["module_id"])), - ) - if ue["semestre_idx"] is not None and mod["semestre_id"] != ue["semestre_idx"]: + tag_cls = "module_tag_editor" if tag_editable else "module_tag_editor_ro" + tag_edit = f"""<span class="sco_tag_edit"> + <form> + <textarea data-module_id="{mod.id}" class="{tag_cls}">{ + ",".join([ tag.title for tag in mod.tags ]) + }</textarea> + </form> + </span>""" + if ue.semestre_idx is not None and mod.semestre_id != ue.semestre_idx: warning_semestre = ' <span class="red">incohérent ?</span>' else: warning_semestre = "" H.append( - " %s %s%s" % (parcours.SESSION_NAME, mod["semestre_id"], warning_semestre) - + " (%s)" % heurescoef - + tag_edit + f""" {parcours.SESSION_NAME} {mod.semestre_id}{warning_semestre} + {heurescoef}{tag_edit}""" ) H.append("</li>") if not modules: @@ -1417,7 +1418,7 @@ def _ue_table_modules( H.append( f"""<a class="stdlink" href="{ url_for("notes.matiere_delete", - scodoc_dept=g.scodoc_dept, matiere_id=mat["matiere_id"])}" + scodoc_dept=g.scodoc_dept, matiere_id=mat.id)}" >la supprimer</a> """ ) @@ -1426,7 +1427,7 @@ def _ue_table_modules( H.append( f"""<li> <a class="stdlink" href="{ url_for("notes.module_create", - scodoc_dept=g.scodoc_dept, matiere_id=mat["matiere_id"])}" + scodoc_dept=g.scodoc_dept, matiere_id=mat.id)}" >{create_element_msg}</a></li> """ ) diff --git a/app/formations/formation_io.py b/app/formations/formation_io.py index bd21357262c0efaafbb7b68435d639ee662bd8a4..bbf265cc5300e70b6570c3e0285ab81adfd5d1a3 100644 --- a/app/formations/formation_io.py +++ b/app/formations/formation_io.py @@ -36,8 +36,8 @@ from flask_login import current_user import app.scodoc.sco_utils as scu from app import db from app import log -from app.formations import edit_matiere, edit_module, edit_ue -from app.models import Formation, FormSemestre, Module, UniteEns +from app.formations import edit_module, edit_ue +from app.models import Formation, FormSemestre, Matiere, Module, UniteEns from app.models import ScolarNews from app.models.but_refcomp import ( ApcAppCritique, @@ -113,35 +113,35 @@ def formation_export_dict( ue_dict.pop("code_apogee_rcue", None) if ue_dict.get("ects") is None: ue_dict.pop("ects", None) - mats = edit_matiere.matiere_list({"ue_id": ue.id}) - mats.sort(key=lambda m: m["numero"] or 0) - ue_dict["matiere"] = mats - for mat in mats: - matiere_id = mat["matiere_id"] + mats = ue.matieres.all() + mats.sort(key=lambda m: m.numero) + mats_dict = [mat.to_dict() for mat in mats] + ue_dict["matiere"] = mats_dict + for mat_d in mats_dict: + matiere_id = mat_d["matiere_id"] if not export_ids: - del mat["id"] - del mat["matiere_id"] - del mat["ue_id"] + del mat_d["id"] + del mat_d["matiere_id"] + del mat_d["ue_id"] mods = edit_module.module_list({"matiere_id": matiere_id}) mods.sort(key=lambda m: (m["numero"] or 0, m["code"])) - mat["module"] = mods - for mod in mods: - module_id = mod["module_id"] + mat_d["module"] = mods + for mod_d in mods: + module: Module = db.session.get(Module, mod_d["module_id"]) if export_tags: - tags = sco_tag_module.module_tag_list(module_id=mod["module_id"]) + tags = [t.title for t in module.tags] if tags: - mod["tags"] = [{"name": x} for x in tags] + mod_d["tags"] = [{"name": x} for x in tags] # - module: Module = db.session.get(Module, module_id) if module.is_apc(): # Exporte les coefficients if ue_reference_style == "id": - mod["coefficients"] = [ + mod_d["coefficients"] = [ {"ue_reference": str(ue_id), "coef": str(coef)} for (ue_id, coef) in module.get_ue_coef_dict().items() ] else: - mod["coefficients"] = [ + mod_d["coefficients"] = [ {"ue_reference": ue_acronyme, "coef": str(coef)} for ( ue_acronyme, @@ -149,29 +149,29 @@ def formation_export_dict( ) in module.get_ue_coef_dict_acronyme().items() ] # Et les parcours - mod["parcours"] = [ + mod_d["parcours"] = [ p.to_dict(with_annees=False) for p in module.parcours ] # Et les AC if ac_as_list: # XML préfère une liste - mod["app_critiques"] = [ + mod_d["app_critiques"] = [ x.to_dict(with_code=True) for x in module.app_critiques ] else: - mod["app_critiques"] = { + mod_d["app_critiques"] = { x.code: x.to_dict() for x in module.app_critiques } if not export_ids: - del mod["id"] - del mod["ue_id"] - del mod["matiere_id"] - del mod["module_id"] - del mod["formation_id"] + del mod_d["id"] + del mod_d["ue_id"] + del mod_d["matiere_id"] + del mod_d["module_id"] + del mod_d["formation_id"] if not export_codes_apo: - del mod["code_apogee"] - if mod["ects"] is None: - del mod["ects"] + del mod_d["code_apogee"] + if mod_d["ects"] is None: + del mod_d["ects"] return f_dict @@ -399,7 +399,8 @@ def formation_import_xml(doc: str | bytes, import_tags=True, use_local_refcomp=F assert mat_info[0] == "matiere" mat_info[1]["ue_id"] = ue_id - mat_id = edit_matiere.do_matiere_create(mat_info[1]) + mat = Matiere.create_from_dict(mat_info[1]) + mat_id = mat.id # -- create modules for mod_info in mat_info[2]: assert mod_info[0] == "module" @@ -460,7 +461,7 @@ def formation_import_xml(doc: str | bytes, import_tags=True, use_local_refcomp=F f"Warning: parcours {code_parcours} inexistant !" ) if import_tags and tag_names: - sco_tag_module.module_tag_set(mod_id, tag_names) + module.set_tags(tag_names) if module.is_apc() and ue_coef_dict: modules_a_coefficienter.append((module, ue_coef_dict)) # Fixe les coefs APC (à la fin pour que les UE soient créées) diff --git a/app/models/formations.py b/app/models/formations.py index 1384873f1cc25bf5380fc38d18c36c166bee436a..5ae12d6f27dd4d1af53453259d624d763d8b542c 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -5,7 +5,7 @@ from flask import abort, g from flask_sqlalchemy.query import Query import app -from app import db +from app import db, log from app.comp import df_cache from app.models import ScoDocModel, SHORT_STR_LEN from app.models.but_refcomp import ( @@ -14,6 +14,7 @@ from app.models.but_refcomp import ( ApcParcours, ApcParcoursNiveauCompetence, ) +from app.models.events import ScolarNews from app.models.modules import Module from app.models.moduleimpls import ModuleImpl from app.models.ues import UniteEns, UEParcours @@ -21,6 +22,7 @@ from app.scodoc import sco_cache from app.scodoc import codes_cursus from app.scodoc import sco_utils as scu from app.scodoc.codes_cursus import UE_STANDARD +from app.scodoc.sco_exceptions import ScoNonEmptyFormationObject, ScoValueError class Formation(ScoDocModel): @@ -166,6 +168,7 @@ class Formation(ScoDocModel): sco_cache.invalidate_formsemestre() def invalidate_cached_sems(self): + "Invalide caches de tous les formssemestres de la formation" for sem in self.formsemestres: sco_cache.invalidate_formsemestre(formsemestre_id=sem.id) @@ -312,7 +315,9 @@ class Matiere(ScoDocModel): titre = db.Column(db.Text()) numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation - modules = db.relationship("Module", lazy="dynamic", backref="matiere") + modules = db.relationship( + "Module", lazy="dynamic", backref="matiere", cascade="all, delete-orphan" + ) _sco_dept_relations = ("UniteEns", "Formation") # accès au dept_id def __repr__(self): @@ -325,5 +330,75 @@ class Matiere(ScoDocModel): e.pop("_sa_instance_state", None) # ScoDoc7 output_formators e["numero"] = e["numero"] if e["numero"] else 0 - e["ue_id"] = self.id + e["matiere_id"] = self.id return e + + def is_locked(self) -> bool: + """True if matiere cannot be be modified + because it contains modules used in a locked formsemestre. + """ + from app.models.formsemestre import FormSemestre + + mat = ( + db.session.query(Matiere) + .filter_by(id=self.id) + .join(Module) + .join(ModuleImpl) + .join(FormSemestre) + .filter_by(etat=False) + .all() + ) + return bool(mat) + + def can_be_deleted(self) -> bool: + "True si la matiere n'est pas utilisée dans des formsemestres" + locked = self.is_locked() + if locked: + return False + if any(m.modimpls.all() for m in self.modules): + return False + return True + + def delete(self): + "Delete matière. News, inval cache." + from app.models import ScolarNews + + formation = self.ue.formation + log(f"matiere.delete: matiere_id={self.id}") + if not self.can_be_deleted(): + # il y a au moins un modimpl dans un module de cette matière + raise ScoNonEmptyFormationObject("Matière", self.titre) + db.session.delete(self) + db.session.commit() + # news + ScolarNews.add( + typ=ScolarNews.NEWS_FORM, + obj=formation.id, + text=f"Modification de la formation {formation.acronyme}", + ) + # cache + formation.invalidate_cached_sems() + + @classmethod + def create_from_dict(cls, data: dict) -> "Matiere": + """Create matière from dict. Log, news, cache. + data must include ue_id, a valid UE id. + Commit session. + """ + # check ue + if data.get("ue_id") is None: + raise ScoValueError("UE id missing") + _ = UniteEns.get_ue(data["ue_id"]) + + mat = super().create_from_dict(data) + db.session.commit() + db.session.refresh(mat) + # news + formation = mat.ue.formation + ScolarNews.add( + typ=ScolarNews.NEWS_FORM, + obj=formation.id, + text=f"Modification de la formation {formation.acronyme}", + ) + formation.invalidate_cached_sems() + return mat diff --git a/app/models/modules.py b/app/models/modules.py index 8e1d8c5b7205fb002777ce577941e43176e65027..50ff5633ecc0ae7a168fe77d84ca6f7d49fa5145 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -1,9 +1,10 @@ """ScoDoc 9 models : Modules """ +import http from flask import current_app, g -from app import db +from app import db, log from app import models from app.models import APO_CODE_STR_LEN from app.models.but_refcomp import ( @@ -75,11 +76,11 @@ class Module(models.ScoDocModel): backref=db.backref("modules", lazy=True), ) - _sco_dept_relations = "Formation" # accès au dept_id + _sco_dept_relations = ("Formation",) # accès au dept_id def __init__(self, **kwargs): self.ue_coefs = [] - super(Module, self).__init__(**kwargs) + super().__init__(**kwargs) def __repr__(self): return f"""<Module{ModuleType(self.module_type or ModuleType.STANDARD).name @@ -449,6 +450,43 @@ class Module(models.ScoDocModel): db.session.add(self) db.session.flush() + def set_tags(self, taglist: str | list[str] | None = None): + """taglist may either be: + a string with tag names separated by commas ("un,deux") + or a list of strings (["un", "deux"]) + Remplace les tags existants + """ + # TODO refactoring ScoTag + # TODO code à moderniser (+ revoir classe ScoTag, utiliser modèle) + # TODO Voir ItemSuiviTag et api etud_suivi + from app.scodoc.sco_tag_module import ScoTag, ModuleTag + + taglist = taglist or [] + if isinstance(taglist, str): + taglist = taglist.split(",") + taglist = [t.strip() for t in taglist] + taglist = [t for t in taglist if t] + log(f"module.set_tags: module_id={self.id} taglist={taglist}") + # Check tags syntax + for tag in taglist: + if not ScoTag.check_tag_title(tag): + log(f"module.set_tags({self.id}): invalid tag title") + return scu.json_error(404, "invalid tag") + + newtags = set(taglist) + oldtags = set(t.title for t in self.tags) + to_del = oldtags - newtags + to_add = newtags - oldtags + + # should be atomic, but it's not. + for tagname in to_add: + t = ModuleTag(tagname, object_id=self.id) + for tagname in to_del: + t = ModuleTag(tagname) + t.remove_tag_from_object(self.id) + + return "", http.HTTPStatus.NO_CONTENT + class ModuleUECoef(db.Model): """Coefficients des modules vers les UE (APC, BUT) @@ -499,7 +537,7 @@ class ModuleUECoef(db.Model): return d -class NotesTag(db.Model): +class NotesTag(models.ScoDocModel): """Tag sur un module""" __tablename__ = "notes_tags" @@ -511,6 +549,9 @@ class NotesTag(db.Model): dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) title = db.Column(db.Text(), nullable=False) + def __repr__(self): + return f"<Tag {self.id} {self.title!r}>" + @classmethod def get_or_create(cls, title: str, dept_id: int | None = None) -> "NotesTag": """Get tag, or create it if it doesn't yet exists. diff --git a/app/pe/moys/pe_ressemtag.py b/app/pe/moys/pe_ressemtag.py index cff92b94e98d39a5466debfb7b5f7c2fa9576a8d..e424e8f3e5464fcabf848f635aaccea44ecb3393 100644 --- a/app/pe/moys/pe_ressemtag.py +++ b/app/pe/moys/pe_ressemtag.py @@ -274,7 +274,8 @@ class ResSemBUTTag(ResultatsSemestreBUT, pe_tabletags.TableTag): pole=None, ) -> pd.DataFrame: """Calcule la moyenne par UE des étudiants pour un tag donné, - en ayant connaissance des informations sur le tag et des inscriptions des étudiants aux différentes UEs. + en ayant connaissance des informations sur le tag et des inscriptions + des étudiants aux différentes UEs. info_tag détermine les modules pris en compte : * si non `None`, seuls les modules rattachés au tag sont pris en compte @@ -342,7 +343,8 @@ class ResSemBUTTag(ResultatsSemestreBUT, pe_tabletags.TableTag): colonnes = [ue.id for ue in self.ues_standards] moyennes_ues_tag = moyennes_ues_tag[colonnes] - # Applique le masque d'inscription aux UE pour ne conserver que les UE dans lequel l'étudiant est inscrit + # Applique le masque d'inscription aux UE pour ne conserver + # que les UE dans lequel l'étudiant est inscrit moyennes_ues_tag = moyennes_ues_tag[colonnes] * ues_inscr_parcours_df[colonnes] # Transforme les UEs en acronyme @@ -405,7 +407,7 @@ class ResSemBUTTag(ResultatsSemestreBUT, pe_tabletags.TableTag): """Vérifie l'unicité des tags""" noms_tags_perso = sorted(list(set(dict_tags["personnalises"].keys()))) noms_tags_auto = sorted(list(set(dict_tags["auto"].keys()))) # + noms_tags_comp - noms_tags = noms_tags_perso + noms_tags_auto + # noms_tags = noms_tags_perso + noms_tags_auto intersection = list(set(noms_tags_perso) & set(noms_tags_auto)) @@ -455,7 +457,7 @@ def get_synthese_tags_personnalises_semestre(formsemestre: FormSemestre): modimpl_id = modimpl.id # Liste des tags pour le module concerné - tags = sco_tag_module.module_tag_list(modimpl.module.id) + tags = [t.title for t in modimpl.module.tags] # Traitement des tags recensés, chacun pouvant étant de la forme # "mathématiques", "théorie", "pe:0", "maths:2" diff --git a/app/scodoc/sco_tag_module.py b/app/scodoc/sco_tag_module.py index 5dfc26a5554824b63e36986106cd91d2fe30d2f2..0d5bdff620e9dc36a3b5ee47d050f10ff6222736 100644 --- a/app/scodoc/sco_tag_module.py +++ b/app/scodoc/sco_tag_module.py @@ -30,16 +30,15 @@ Implementation expérimentale (Jul. 2016) pour grouper les modules sur les avis de poursuites d'études. + TODO: réécrire avec SQLAlchemy. Pour l'UI, voir https://goodies.pixabay.com/jquery/tag-editor/demo.html """ -import http import re from flask import g -from app import db, log -from app.formations import edit_module +from app import db from app.models import Formation, NotesTag import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb @@ -60,7 +59,7 @@ from app.scodoc.sco_exceptions import ScoValueError # NOTA: ancien code, n'utile pas de modèles SQLAlchemy -class ScoTag(object): +class ScoTag: """Generic tags for ScoDoc""" # must be overloaded: @@ -208,8 +207,6 @@ class ModuleTag(ScoTag): # API - - # TODO placer dans la vraie API et ne plus utiliser sco_publish def module_tag_search(term: str | int): """List all used tag names (for auto-completion)""" @@ -230,60 +227,6 @@ def module_tag_search(term: str | int): return scu.sendJSON(data) -def module_tag_list(module_id="") -> list[str]: - """les noms de tags associés à ce module""" - r = ndb.SimpleDictFetch( - """SELECT t.title - FROM notes_modules_tags mt, notes_tags t - WHERE mt.tag_id = t.id - AND mt.module_id = %(module_id)s - """, - {"module_id": module_id}, - ) - return [x["title"] for x in r] - - -def module_tag_set(module_id="", taglist=None): - """taglist may either be: - a string with tag names separated by commas ("un,deux") - or a list of strings (["un", "deux"]) - Remplace les tags existants - """ - if not taglist: - taglist = [] - elif isinstance(taglist, str): - taglist = taglist.split(",") - taglist = [t.strip() for t in taglist] - log(f"module_tag_set: module_id={module_id} taglist={taglist}") - # Check tags syntax - for tag in taglist: - if not ScoTag.check_tag_title(tag): - log(f"module_tag_set({module_id}): invalid tag title") - return scu.json_error(404, "invalid tag") - - # TODO code à moderniser (+ revoir classe ScoTag, utiliser modèle) - # TODO Voir ItemSuiviTag et api etud_suivi - - # Sanity check: - mod_dict = edit_module.module_list(args={"module_id": module_id}) - if not mod_dict: - raise ScoValueError("invalid module !") - - newtags = set(taglist) - oldtags = set(module_tag_list(module_id)) - to_del = oldtags - newtags - to_add = newtags - oldtags - - # should be atomic, but it's not. - for tagname in to_add: - t = ModuleTag(tagname, object_id=module_id) - for tagname in to_del: - t = ModuleTag(tagname) - t.remove_tag_from_object(module_id) - - return "", http.HTTPStatus.NO_CONTENT - - def split_tagname_coeff(tag: str, separateur=":") -> tuple[str, float]: """Découpage d'un tag, tel que saisi par un utilisateur dans le programme, pour en extraire : diff --git a/app/scodoc/sco_ue_external.py b/app/scodoc/sco_ue_external.py index b795002936da6f1fc35be7e158a267cf3140c89c..97f97259c9a0d51ab2b822bb8a7c2704aa7390d4 100644 --- a/app/scodoc/sco_ue_external.py +++ b/app/scodoc/sco_ue_external.py @@ -56,12 +56,12 @@ Solution proposée (nov 2014): import flask from flask import flash, g, request, render_template, url_for from flask_login import current_user -from app.formations import edit_matiere, edit_module, edit_ue +from app.formations import edit_module, edit_ue from app.models.formsemestre import FormSemestre from app import db, log -from app.models import Evaluation, Identite, ModuleImpl, UniteEns +from app.models import Evaluation, Identite, Matiere, ModuleImpl, UniteEns from app.scodoc import codes_cursus from app.scodoc import sco_moduleimpl from app.scodoc import sco_saisie_notes @@ -110,7 +110,7 @@ def external_ue_create( ) ue = db.session.get(UniteEns, ue_id) flash(f"UE créée (code {ue.ue_code})") - matiere_id = edit_matiere.do_matiere_create( + matiere = Matiere.create_from_dict( {"ue_id": ue_id, "titre": titre or acronyme, "numero": 1} ) @@ -120,7 +120,7 @@ def external_ue_create( "code": acronyme, "coefficient": ects, # tous le coef. module est egal à la quantite d'ECTS "ue_id": ue_id, - "matiere_id": matiere_id, + "matiere_id": matiere.id, "formation_id": formation_id, "semestre_id": formsemestre.semestre_id, "module_type": scu.ModuleType.STANDARD, diff --git a/app/views/notes.py b/app/views/notes.py index 4aa70730972b43c521448c6e4f38dbd2593c9b79..855eceedd10b53dc26c73d70274df80568b134b3 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -612,9 +612,7 @@ def formation_tag_modules_by_type(formation_id: int, semestre_idx: int): """Taggue tous les modules de la formation en fonction de leur type : 'res', 'sae', 'malus' Ne taggue pas les modules standards. """ - formation = Formation.query.filter_by( - id=formation_id, dept_id=g.scodoc_dept_id - ).first_or_404() + formation = Formation.get_formation(formation_id) sco_tag_module.formation_tag_modules_by_type(formation) flash("Formation tagguée") return flask.redirect( @@ -631,11 +629,12 @@ def formation_tag_modules_by_type(formation_id: int, semestre_idx: int): @bp.route("/module_tag_set", methods=["POST"]) @scodoc @permission_required(Permission.EditFormationTags) -def module_tag_set(): +def module_tag_set(): # TODO passer dans l'API """Set tags on module""" - module_id = int(request.form.get("module_id")) + module_id = request.form.get("module_id") + module: Module = Module.get_instance(module_id) taglist = request.form.get("taglist") - return sco_tag_module.module_tag_set(module_id, taglist) + return module.set_tags(taglist) @bp.route("/module_clone", methods=["POST"]) @@ -643,8 +642,8 @@ def module_tag_set(): @permission_required(Permission.EditFormation) def module_clone(): """Clone existing module""" - module_id = int(request.form.get("module_id")) - module = Module.query.get_or_404(module_id) + module_id = request.form.get("module_id") + module: Module = Module.get_instance(module_id) module2 = module.clone() db.session.add(module2) db.session.commit() diff --git a/tests/unit/sco_fake_gen.py b/tests/unit/sco_fake_gen.py index 61bbb8f919069f435e5d566842229e6f41f3b223..ef3976826808cc89f93f03c8baeb0eeb0031317c 100644 --- a/tests/unit/sco_fake_gen.py +++ b/tests/unit/sco_fake_gen.py @@ -205,11 +205,8 @@ class ScoFake(object): @logging_meth def create_matiere(self, ue_id=None, titre=None, numero=0) -> int: - oid = edit_matiere.do_matiere_create(locals()) - oids = edit_matiere.matiere_list(args={"matiere_id": oid}) - if not oids: - raise ScoValueError("matiere not created !") - return oid + mat = Matiere.create_from_dict(locals()) + return mat.id @logging_meth def create_module( diff --git a/tests/unit/test_formations.py b/tests/unit/test_formations.py index 66eea7867bb14966aab074d1a0040392bd81fc5c..e234e1ff5053ef855e23fe2880444b7a7f0b5990 100644 --- a/tests/unit/test_formations.py +++ b/tests/unit/test_formations.py @@ -36,8 +36,6 @@ # - do_formsemestre_delete # - module_list # - do_module_delete -# - matiere_list -# - do_matiere_delete # - ue_list # - do_ue_delete # - do_formation_delete @@ -50,12 +48,11 @@ import pytest from app import db from app.formations import ( edit_formation, - edit_matiere, edit_module, edit_ue, formation_io, ) -from app.models import Formation, ModuleImpl +from app.models import Formation, Matiere, ModuleImpl from app.scodoc import sco_formsemestre from app.scodoc import sco_exceptions from app.scodoc import sco_formsemestre_edit @@ -287,10 +284,12 @@ def test_formations(test_client): assert len(lim_sem2) == 0 # deuxieme vérification si le module s'est bien sup - li_mat = edit_matiere.matiere_list() + li_mat = Matiere.query.all() assert len(li_mat) == 4 - edit_matiere.do_matiere_delete(oid=matiere_id3) # on supprime la matiere - li_mat2 = edit_matiere.matiere_list() + assert matiere_id3 in [m.matiere_id for m in li_mat] + matiere = db.session.get(Matiere, matiere_id3) + matiere.delete() # on supprime la matiere + li_mat2 = Matiere.query.all() assert len(li_mat2) == 3 # verification de la suppression de la matiere li_ue = edit_ue.ue_list()