From 9237ff47d013d1ffcef3d3c77e43e0a981b3e1d8 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 21 Mar 2023 21:14:38 +0100 Subject: [PATCH] =?UTF-8?q?Changement=20de=20formation=20d'un=20formsemest?= =?UTF-8?q?re.=20Corrige=20form=20association.=20R=C3=A9organisation=20de?= =?UTF-8?q?=20code.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/forms/formsemestre/change_formation.py | 63 ++++ app/models/modules.py | 4 + app/scodoc/sco_formation_versions.py | 317 ++++++++++++++++++ app/scodoc/sco_formations.py | 68 ++-- app/scodoc/sco_formsemestre_edit.py | 207 +----------- .../formsemestre/change_formation.j2 | 30 ++ app/views/__init__.py | 11 +- app/views/notes.py | 3 +- app/views/notes_formsemestre.py | 110 ++++++ sco_version.py | 2 +- tests/unit/test_formsemestre.py | 6 +- 11 files changed, 590 insertions(+), 231 deletions(-) create mode 100644 app/forms/formsemestre/change_formation.py create mode 100644 app/scodoc/sco_formation_versions.py create mode 100644 app/templates/formsemestre/change_formation.j2 create mode 100644 app/views/notes_formsemestre.py diff --git a/app/forms/formsemestre/change_formation.py b/app/forms/formsemestre/change_formation.py new file mode 100644 index 000000000..4becfea5e --- /dev/null +++ b/app/forms/formsemestre/change_formation.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2023 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 +# +############################################################################## + +""" +Formulaire changement formation +""" + +from flask_wtf import FlaskForm +from wtforms import RadioField, SubmitField, validators + +from app.models import Formation + + +class FormSemestreChangeFormationForm(FlaskForm): + "Formulaire changement formation d'un formsemestre" + # consrtuit dynamiquement ci-dessous + + +def gen_formsemestre_change_formation_form( + formations: list[Formation], +) -> FormSemestreChangeFormationForm: + "Create our dynamical form" + # see https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition + class F(FormSemestreChangeFormationForm): + pass + + setattr( + F, + "radio_but", + RadioField( + "Label", + choices=[ + (formation.id, formation.get_titre_version()) + for formation in formations + ], + ), + ) + setattr(F, "submit", SubmitField("Changer la formation")) + setattr(F, "cancel", SubmitField("Annuler")) + return F() diff --git a/app/models/modules.py b/app/models/modules.py index 2ea75b66a..1e89db7f9 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -221,6 +221,10 @@ class Module(db.Model): """returns { ue_id : coef }""" return {p.ue.id: p.coef for p in self.ue_coefs} + def get_ue_coef_dict_acronyme(self): + """returns { ue_acronyme : coef }""" + return {p.ue.acronyme: p.coef for p in self.ue_coefs} + def delete_ue_coef(self, ue): """delete coef""" if self.formation.has_locked_sems(self.ue.semestre_idx): diff --git a/app/scodoc/sco_formation_versions.py b/app/scodoc/sco_formation_versions.py new file mode 100644 index 000000000..73a229501 --- /dev/null +++ b/app/scodoc/sco_formation_versions.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2023 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 +# +############################################################################## + +"""Association de nouvelles versions de formation à des formsemestre +""" +import flask +from flask import url_for, flash +from flask import g, request + +from app import db +from app.models import ( + Module, + ModuleImpl, + Evaluation, + EvaluationUEPoids, + UniteEns, +) + +from app.models.formations import Formation +from app.models.formsemestre import FormSemestre +import app.scodoc.notesdb as ndb +import app.scodoc.sco_utils as scu + +from app import log +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc import sco_etud +from app.scodoc import sco_formations +from app.scodoc import sco_formsemestre +from app.scodoc import sco_moduleimpl +from app.scodoc import sco_cursus_dut + + +def formsemestre_associate_new_version( + formation_id: int, + formsemestre_id: int = None, + other_formsemestre_ids: list[int] = None, +): + """Formulaire nouvelle version formation et association d'un ou plusieurs formsemestre. + formation_id: la formation à dupliquer + formsemestre_id: optionnel, formsemestre de départ, qui sera associé à la noiuvelle version + """ + if formsemestre_id is not None: + formsemestre_id = int(formsemestre_id) + formation: Formation = Formation.query.get_or_404(formation_id) + other_formsemestre_ids = {int(x) for x in other_formsemestre_ids or []} + if request.method == "GET": + # dresse la liste des semestres non verrouillés de la même formation + other_formsemestres: list[FormSemestre] = formation.formsemestres.filter_by( + etat=True + ) + + H = [] + for other_formsemestre in other_formsemestres: + checked = ( + 'checked="checked"' + if ( + other_formsemestre.id == formsemestre_id + or other_formsemestre.id in other_formsemestre_ids + ) + else "" + ) + disabled = ( + 'disabled="1"' if other_formsemestre.id == formsemestre_id else "" + ) + + H.append( + f"""
{other_formsemestre.titre_mois()}
""" + ) + if formsemestre_id is None: + cancel_url = url_for( + "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id + ) + else: + cancel_url = url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + + return scu.confirm_dialog( + ( + """

Associer à une nouvelle version de formation non verrouillée ?

""" + if formsemestre_id + else """

Créer une nouvelle version de la formation ?

""" + ) + + f"""

Formation: {formation.titre} version {formation.version}

+ +

Le programme pédagogique ("formation") va être dupliqué + pour que vous puissiez le modifier sans affecter les semestres déjà terminés. +

+

Veillez à ne pas abuser de cette possibilité, car créer + trop de versions de formations va vous compliquer la gestion + (à vous de garder trace des différences et à ne pas vous + tromper par la suite...). +

+
+

Si vous voulez associer des semestres à la nouvelle + version, cochez-les maintenant
+ (attention : vous ne pourrez pas le faire plus tard car on ne peut pas + changer la formation d'un semestre !): +

+ { "".join(H) } +

Les données (étudiants, notes...) de ces semestres seront inchangées.

+
+
+ Vous pouvez aussi essayer d'associer ce semestre à une autre formation identique. +
+ """, + OK="Créer une nouvelle version et y associer ces semestres", + dest_url="", + cancel_url=cancel_url, + parameters={ + "formation_id": formation_id, + "formsemestre_id": formsemestre_id, + }, + ) + elif request.method == "POST": + if formsemestre_id is not None: # pas dans le form car checkbox disabled + other_formsemestre_ids |= {formsemestre_id} + new_formation_id = do_formsemestres_associate_new_version( + formation_id, other_formsemestre_ids + ) + flash( + "Nouvelle version de la formation créée" + + (" et semestres associés." if other_formsemestre_ids else ".") + ) + if formsemestre_id is None: + return flask.redirect( + url_for( + "notes.ue_table", + scodoc_dept=g.scodoc_dept, + formation_id=new_formation_id, + ) + ) + else: + return flask.redirect( + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) + else: + raise ScoValueError("Méthode invalide") + + +def do_formsemestres_associate_new_version( + formation_id: int, formsemestre_ids: list[int] +) -> int: + """Crée une nouvelle version de la formation du semestre, et y rattache les semestres. + Tous les moduleimpl sont ré-associés à la nouvelle formation, ainsi que les decisions de jury + si elles existent (codes d'UE validées). + Les semestre doivent tous appartenir à la meme version de la formation. + renvoie l'id de la nouvelle formation. + """ + log(f"do_formsemestres_associate_new_version {formation_id} {formsemestre_ids}") + + # Check: tous les semestre de la formation + formsemestres = [FormSemestre.query.get_or_404(i) for i in formsemestre_ids] + if not all( + [formsemestre.formation_id == formation_id for formsemestre in formsemestres] + ): + raise ScoValueError("les semestres ne sont pas tous de la même formation !") + + cnx = ndb.GetDBConnexion() + # New formation: + ( + formation_id, + modules_old2new, + ues_old2new, + ) = sco_formations.formation_create_new_version(formation_id, redirect=False) + # Log new ues: + for ue_id in ues_old2new: + ue = UniteEns.query.get(ue_id) + new_ue = UniteEns.query.get(ues_old2new[ue_id]) + assert ue.semestre_idx == new_ue.semestre_idx + log(f"{ue} -> {new_ue}") + # Log new modules + for module_id in modules_old2new: + mod = Module.query.get(module_id) + new_mod = Module.query.get(modules_old2new[module_id]) + assert mod.semestre_id == new_mod.semestre_id + log(f"{mod} -> {new_mod}") + # re-associate + for formsemestre_id in formsemestre_ids: + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + sem["formation_id"] = formation_id + sco_formsemestre.do_formsemestre_edit(sem, cnx=cnx, html_quote=False) + _reassociate_moduleimpls(cnx, formsemestre_id, ues_old2new, modules_old2new) + + cnx.commit() + return formation_id + + +def _reassociate_moduleimpls(cnx, formsemestre_id, ues_old2new, modules_old2new): + """Associe les moduleimpls d'un semestre existant à un autre programme + et met à jour les décisions de jury (validations d'UE). + """ + # re-associate moduleimpls to new modules: + modimpls = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) + for mod in modimpls: + mod["module_id"] = modules_old2new[mod["module_id"]] + sco_moduleimpl.do_moduleimpl_edit(mod, formsemestre_id=formsemestre_id) + # Update poids des évaluations + # les poids associent les évaluations aux UE (qui ont changé d'id) + for poids in EvaluationUEPoids.query.filter( + EvaluationUEPoids.evaluation_id == Evaluation.id, + Evaluation.moduleimpl_id == ModuleImpl.id, + ModuleImpl.formsemestre_id == formsemestre_id, + ): + poids.ue_id = ues_old2new[poids.ue_id] + db.session.add(poids) + db.session.commit() + + # update decisions: + events = sco_etud.scolar_events_list(cnx, args={"formsemestre_id": formsemestre_id}) + for e in events: + if e["ue_id"]: + e["ue_id"] = ues_old2new[e["ue_id"]] + sco_etud.scolar_events_edit(cnx, e) + validations = sco_cursus_dut.scolar_formsemestre_validation_list( + cnx, args={"formsemestre_id": formsemestre_id} + ) + for e in validations: + if e["ue_id"]: + e["ue_id"] = ues_old2new[e["ue_id"]] + # log('e=%s' % e ) + sco_cursus_dut.scolar_formsemestre_validation_edit(cnx, e) + + +def formations_are_equals( + formation1: Formation, formation2: Formation = None, formation2_dict: dict = None +) -> bool: + """True if the two formations are exactly the same, except for their versions. + Can specify either formation2 or its dict repr. + """ + fd1 = sco_formations.formation_export_dict( + formation1, export_external_ues=True, ue_reference_style="acronyme" + ) + if formation2_dict is None: + if formation2 is None: + raise ValueError("must specify formation2 or formation2_dict") + formation2_dict = sco_formations.formation_export_dict( + formation2, export_external_ues=True, ue_reference_style="acronyme" + ) + del fd1["version"] + if "version" in formation2_dict: + del formation2_dict["version"] + return fd1 == formation2_dict + + +def formsemestre_change_formation(formsemestre: FormSemestre, new_formation: Formation): + """Change la formation d'un semestre. La nouvelle formation doit avoir exactement + le même contenu que l'actuelle, à l'exception du numéro de version. + """ + if not formations_are_equals(formsemestre.formation, new_formation): + raise ScoValueError( + "formsemestre_change_formation: les deux formations diffèrent" + ) + log( + f"formsemestre_change_formation: formsemestre {formsemestre} to formation {new_formation}" + ) + # Il faut ré-associer tous les modimpls + for modimpl in formsemestre.modimpls: + old_module: Module = modimpl.module + new_module: Module = ( + Module.query.filter_by( + formation_id=new_formation.id, + code=old_module.code, + titre=old_module.titre, + ) + .join(UniteEns) + .filter_by(acronyme=old_module.ue.acronyme) + .first() + ) + if new_module is None: + raise ValueError( + f"formsemestre_change_formation: erreur sur module {old_module}" + ) + modimpl.module = new_module + db.session.add(modimpl) + formsemestre.formation = new_formation + db.session.commit() diff --git a/app/scodoc/sco_formations.py b/app/scodoc/sco_formations.py index 6d167afa2..be7e348c4 100644 --- a/app/scodoc/sco_formations.py +++ b/app/scodoc/sco_formations.py @@ -27,11 +27,10 @@ """Import / Export de formations """ -from operator import itemgetter import xml.dom.minidom import flask -from flask import flash, g, url_for, request +from flask import flash, g, url_for from flask_login import current_user import app.scodoc.sco_utils as scu @@ -52,7 +51,6 @@ from app.scodoc import codes_cursus from app.scodoc import sco_edit_matiere from app.scodoc import sco_edit_module from app.scodoc import sco_edit_ue -from app.scodoc import sco_formsemestre from app.scodoc import sco_preferences from app.scodoc import sco_tag_module from app.scodoc import sco_xml @@ -95,18 +93,19 @@ def formation_list(formation_id=None, args={}): ### XXX obsolete, à supprimer return r -def formation_export( - formation_id, +def formation_export_dict( + formation: Formation, export_ids=False, export_tags=True, export_external_ues=False, export_codes_apo=True, - format=None, -): - """Get a formation, with UE, matieres, modules - in desired format + ac_as_list=False, + ue_reference_style="id", +) -> dict: + """Get a formation, with UE, matieres, modules... + as a deep dict. + ac_as_list spécifie le format des Appentissages Critiques. """ - formation: Formation = Formation.query.get_or_404(formation_id) f_dict = formation.to_dict(with_refcomp_attrs=True) if not export_ids: del f_dict["id"] @@ -131,7 +130,8 @@ def formation_export( # Et le parcour: if ue.parcour: ue_dict["parcour"] = [ue.parcour.to_dict(with_annees=False)] - ue_dict["reference"] = ue.id # pour les coefficients + # pour les coefficients: + ue_dict["reference"] = ue.id if ue_reference_style == "id" else ue.acronyme if not export_ids: for id_id in ( "id", @@ -165,19 +165,28 @@ def formation_export( if tags: mod["tags"] = [{"name": x} for x in tags] # - module = Module.query.get(module_id) + module: Module = Module.query.get(module_id) if module.is_apc(): # Exporte les coefficients - mod["coefficients"] = [ - {"ue_reference": str(ue_id), "coef": str(coef)} - for (ue_id, coef) in module.get_ue_coef_dict().items() - ] + if ue_reference_style == "id": + mod["coefficients"] = [ + {"ue_reference": str(ue_id), "coef": str(coef)} + for (ue_id, coef) in module.get_ue_coef_dict().items() + ] + else: + mod["coefficients"] = [ + {"ue_reference": ue_acronyme, "coef": str(coef)} + for ( + ue_acronyme, + coef, + ) in module.get_ue_coef_dict_acronyme().items() + ] # Et les parcours mod["parcours"] = [ p.to_dict(with_annees=False) for p in module.parcours ] # Et les AC - if format == "xml": + if ac_as_list: # XML préfère une liste mod["app_critiques"] = [ x.to_dict(with_code=True) for x in module.app_critiques @@ -196,7 +205,29 @@ def formation_export( del mod["code_apogee"] if mod["ects"] is None: del mod["ects"] + return f_dict + +def formation_export( + formation_id, + export_ids=False, + export_tags=True, + export_external_ues=False, + export_codes_apo=True, + format=None, +) -> flask.Response: + """Get a formation, with UE, matieres, modules + in desired format + """ + formation: Formation = Formation.query.get_or_404(formation_id) + f_dict = formation_export_dict( + formation, + export_ids=export_ids, + export_tags=export_tags, + export_external_ues=export_external_ues, + export_codes_apo=export_codes_apo, + ac_as_list=format == "xml", + ) filename = f"scodoc_formation_{formation.departement.acronym}_{formation.acronyme or ''}_v{formation.version}" return scu.sendResult( f_dict, @@ -597,5 +628,4 @@ def formation_create_new_version(formation_id, redirect=True): msg="Nouvelle version !", ) ) - else: - return new_id, modules_old2new, ues_old2new + return new_id, modules_old2new, ues_old2new diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index 266e364cf..999fd5253 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -39,7 +39,6 @@ from app.models import ( Module, ModuleImpl, Evaluation, - EvaluationUEPoids, UniteEns, ScolarFormSemestreValidation, ScolarAutorisationInscription, @@ -64,14 +63,11 @@ from app.scodoc import codes_cursus from app.scodoc import sco_compute_moy from app.scodoc import sco_edit_module from app.scodoc import sco_edit_ue -from app.scodoc import sco_etud from app.scodoc import sco_evaluation_db -from app.scodoc import sco_formations from app.scodoc import sco_formsemestre from app.scodoc import sco_groups_copy from app.scodoc import sco_modalites from app.scodoc import sco_moduleimpl -from app.scodoc import sco_cursus_dut from app.scodoc import sco_permissions_check from app.scodoc import sco_portal_apogee from app.scodoc import sco_preferences @@ -1200,7 +1196,7 @@ def do_formsemestre_clone( """Clone a semestre: make copy, same modules, same options, same resps, same partitions. New dates, responsable_id """ - log(f"cloning orig_formsemestre_id") + log(f"do_formsemestre_clone: {orig_formsemestre_id}") formsemestre_orig: FormSemestre = FormSemestre.query.get_or_404( orig_formsemestre_id ) @@ -1291,207 +1287,6 @@ def do_formsemestre_clone( return formsemestre_id -# --------------------------------------------------------------------------------------- - - -def formsemestre_associate_new_version( - formation_id: int, - formsemestre_id: int = None, - other_formsemestre_ids: list[int] = None, -): - """Formulaire nouvelle version formation et association d'un ou plusieurs formsemestre. - formation_id: la formation à dupliquer - formsemestre_id: optionnel, formsemestre de départ, qui sera associé à la noiuvelle version - """ - if formsemestre_id is not None: - formsemestre_id = int(formsemestre_id) - formation: Formation = Formation.query.get_or_404(formation_id) - other_formsemestre_ids = {int(x) for x in other_formsemestre_ids or []} - if request.method == "GET": - # dresse la liste des semestres non verrouillés de la même formation - other_formsemestres: list[FormSemestre] = formation.formsemestres.filter_by( - etat=True - ) - - H = [] - for other_formsemestre in other_formsemestres: - checked = ( - 'checked="checked"' - if ( - other_formsemestre.id == formsemestre_id - or other_formsemestre.id in other_formsemestre_ids - ) - else "" - ) - disabled = ( - 'disabled="1"' if other_formsemestre.id == formsemestre_id else "" - ) - - H.append( - f"""
{other_formsemestre.titre_mois()}
""" - ) - if formsemestre_id is None: - cancel_url = url_for( - "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id - ) - else: - cancel_url = url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ) - - return scu.confirm_dialog( - ( - """

Associer à une nouvelle version de formation non verrouillée ?

""" - if formsemestre_id - else """

Créer une nouvelle version de la formation ?

""" - ) - + f"""

Formation: {formation.titre} version {formation.version}

- -

Le programme pédagogique ("formation") va être dupliqué - pour que vous puissiez le modifier sans affecter les semestres déjà terminés. -

-

Veillez à ne pas abuser de cette possibilité, car créer - trop de versions de formations va vous compliquer la gestion - (à vous de garder trace des différences et à ne pas vous - tromper par la suite...). -

-
-

Si vous voulez associer des semestres à la nouvelle - version, cochez-les maintenant
- (attention : vous ne pourrez pas le faire plus tard car on ne peut pas - changer la formation d'un semestre !): -

""" - + "".join(H) - + """

Les données (étudiants, notes...) de ces semestres seront inchangées.

""" - + "
", - OK="Créer une nouvelle version et y associer ces semestres", - dest_url="", - cancel_url=cancel_url, - parameters={"formation_id": formation_id}, - ) - elif request.method == "POST": - if formsemestre_id is not None: # pas dans le form car checkbox disabled - other_formsemestre_ids |= {formsemestre_id} - new_formation_id = do_formsemestres_associate_new_version( - formation_id, other_formsemestre_ids - ) - flash( - "Nouvelle version de la formation créée" - + (" et semestres associés." if other_formsemestre_ids else ".") - ) - if formsemestre_id is None: - return flask.redirect( - url_for( - "notes.ue_table", - scodoc_dept=g.scodoc_dept, - formation_id=new_formation_id, - ) - ) - else: - return flask.redirect( - url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ) - ) - else: - raise ScoValueError("Méthode invalide") - - -def do_formsemestres_associate_new_version( - formation_id: int, formsemestre_ids: list[int] -) -> int: - """Crée une nouvelle version de la formation du semestre, et y rattache les semestres. - Tous les moduleimpl sont ré-associés à la nouvelle formation, ainsi que les decisions de jury - si elles existent (codes d'UE validées). - Les semestre doivent tous appartenir à la meme version de la formation. - renvoie l'id de la nouvelle formation. - """ - log(f"do_formsemestres_associate_new_version {formation_id} {formsemestre_ids}") - - # Check: tous les semestre de la formation - formsemestres = [FormSemestre.query.get_or_404(i) for i in formsemestre_ids] - if not all( - [formsemestre.formation_id == formation_id for formsemestre in formsemestres] - ): - raise ScoValueError("les semestres ne sont pas tous de la même formation !") - - cnx = ndb.GetDBConnexion() - # New formation: - ( - formation_id, - modules_old2new, - ues_old2new, - ) = sco_formations.formation_create_new_version(formation_id, redirect=False) - # Log new ues: - for ue_id in ues_old2new: - ue = UniteEns.query.get(ue_id) - new_ue = UniteEns.query.get(ues_old2new[ue_id]) - assert ue.semestre_idx == new_ue.semestre_idx - log(f"{ue} -> {new_ue}") - # Log new modules - for module_id in modules_old2new: - mod = Module.query.get(module_id) - new_mod = Module.query.get(modules_old2new[module_id]) - assert mod.semestre_id == new_mod.semestre_id - log(f"{mod} -> {new_mod}") - # re-associate - for formsemestre_id in formsemestre_ids: - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - sem["formation_id"] = formation_id - sco_formsemestre.do_formsemestre_edit(sem, cnx=cnx, html_quote=False) - _reassociate_moduleimpls(cnx, formsemestre_id, ues_old2new, modules_old2new) - - cnx.commit() - return formation_id - - -def _reassociate_moduleimpls(cnx, formsemestre_id, ues_old2new, modules_old2new): - """Associe les moduleimpls d'un semestre existant à un autre programme - et met à jour les décisions de jury (validations d'UE). - """ - # re-associate moduleimpls to new modules: - modimpls = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) - for mod in modimpls: - mod["module_id"] = modules_old2new[mod["module_id"]] - sco_moduleimpl.do_moduleimpl_edit(mod, formsemestre_id=formsemestre_id) - # Update poids des évaluations - # les poids associent les évaluations aux UE (qui ont changé d'id) - for poids in EvaluationUEPoids.query.filter( - EvaluationUEPoids.evaluation_id == Evaluation.id, - Evaluation.moduleimpl_id == ModuleImpl.id, - ModuleImpl.formsemestre_id == formsemestre_id, - ): - poids.ue_id = ues_old2new[poids.ue_id] - db.session.add(poids) - db.session.commit() - - # update decisions: - events = sco_etud.scolar_events_list(cnx, args={"formsemestre_id": formsemestre_id}) - for e in events: - if e["ue_id"]: - e["ue_id"] = ues_old2new[e["ue_id"]] - sco_etud.scolar_events_edit(cnx, e) - validations = sco_cursus_dut.scolar_formsemestre_validation_list( - cnx, args={"formsemestre_id": formsemestre_id} - ) - for e in validations: - if e["ue_id"]: - e["ue_id"] = ues_old2new[e["ue_id"]] - # log('e=%s' % e ) - sco_cursus_dut.scolar_formsemestre_validation_edit(cnx, e) - - def formsemestre_delete(formsemestre_id): """Delete a formsemestre (affiche avertissements)""" formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) diff --git a/app/templates/formsemestre/change_formation.j2 b/app/templates/formsemestre/change_formation.j2 new file mode 100644 index 000000000..2fccdb272 --- /dev/null +++ b/app/templates/formsemestre/change_formation.j2 @@ -0,0 +1,30 @@ +{% extends "sco_page.j2" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block styles %} +{{super()}} +{% endblock %} + +{% block app_content %} + +
+

Changement de la formation du semestre

+ +

On ne peut pas changer la formation d'un semestre existant car +elle défini son organisation (modules, ...), SAUF si la nouvelle formation a +exactement le même contenu que l'existante. +Cela peut arriver par exemple lorsqu'on crée une nouvelle version (pas encore modifiée) +et que l'on a oublié d'y rattacher un semestre. +

+ +{% if formations %} +
+
+ {{ wtf.quick_form(form) }} +
+
+{% else %} +
Aucune formation ne peut se substituer à celle de ce semestre.
+{% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/app/views/__init__.py b/app/views/__init__.py index 3de17f334..b33faf3e3 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -105,4 +105,13 @@ class ScoData: self.prefs = sco_preferences.SemPreferences(formsemestre_id) -from app.views import scodoc, notes, scolar, absences, users, pn_modules, refcomp +from app.views import ( + absences, + notes_formsemestre, + notes, + pn_modules, + refcomp, + scodoc, + scolar, + users, +) diff --git a/app/views/notes.py b/app/views/notes.py index 383252748..046e078f1 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -109,6 +109,7 @@ from app.scodoc import sco_evaluation_recap from app.scodoc import sco_export_results from app.scodoc import sco_formations from app.scodoc import sco_formation_recap +from app.scodoc import sco_formation_versions from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre_custommenu from app.scodoc import sco_formsemestre_edit @@ -188,7 +189,7 @@ sco_publish( ) sco_publish( "/formsemestre_associate_new_version", - sco_formsemestre_edit.formsemestre_associate_new_version, + sco_formation_versions.formsemestre_associate_new_version, Permission.ScoChangeFormation, methods=["GET", "POST"], ) diff --git a/app/views/notes_formsemestre.py b/app/views/notes_formsemestre.py new file mode 100644 index 000000000..9adb88e1a --- /dev/null +++ b/app/views/notes_formsemestre.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2023 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 +# +############################################################################## + +""" +Vues "modernes" des formsemestre +Emmanuel Viennet, 2023 +""" + +import flask +from flask import abort, flash, redirect, render_template, url_for +from flask import g, request +from flask_login import current_user +from wtforms.validators import ValidationError + +from app.decorators import ( + scodoc, + permission_required, +) +from app.forms.formsemestre import change_formation +from app.models import Formation, FormSemestre +from app.scodoc import sco_formations, sco_formation_versions +from app.scodoc.sco_permissions import Permission +from app.views import notes_bp as bp +from app.views import ScoData + + +@bp.route( + "/formsemestre_change_formation/", methods=["GET", "POST"] +) +@scodoc +@permission_required(Permission.ScoImplement) +def formsemestre_change_formation(formsemestre_id: int): + """Propose de changer un formsemestre de formation. + Cette opération est bien sûr impossible... sauf si les deux formations sont identiques. + Par exemple, on vient de créer une formation, et on a oublié d'y associé un formsemestre + existant. + """ + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + formation_dict = sco_formations.formation_export_dict( + formsemestre.formation, export_external_ues=True, ue_reference_style="acronyme" + ) + formations = [ + formation + for formation in Formation.query.filter_by( + dept_id=formsemestre.dept_id, acronyme=formsemestre.formation.acronyme + ) + if formation.id != formsemestre.formation.id + and sco_formation_versions.formations_are_equals( + formation, formation2_dict=formation_dict + ) + ] + form = change_formation.gen_formsemestre_change_formation_form(formations) + if request.method == "POST" and form.validate: + if not form.cancel.data: + new_formation_id = form.radio_but.data + if new_formation_id is None: # pas de choix radio + flash("Pas de formation sélectionnée !") + return render_template( + "formsemestre/change_formation.j2", + form=form, + formations=formations, + formsemestre=formsemestre, + sco=ScoData(formsemestre=formsemestre), + ) + else: + new_formation: Formation = Formation.query.filter_by( + dept_id=g.scodoc_dept_id, formation_id=new_formation_id + ).first_or_404() + sco_formation_versions.formsemestre_change_formation( + formsemestre, new_formation + ) + flash("Formation du semestre modifiée") + return redirect( + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) + # GET + return render_template( + "formsemestre/change_formation.j2", + form=form, + formations=formations, + formsemestre=formsemestre, + sco=ScoData(formsemestre=formsemestre), + ) diff --git a/sco_version.py b/sco_version.py index f2afe9a7e..d6f99bdf7 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.68" +SCOVERSION = "9.4.69" SCONAME = "ScoDoc" diff --git a/tests/unit/test_formsemestre.py b/tests/unit/test_formsemestre.py index 981b114ec..029450444 100644 --- a/tests/unit/test_formsemestre.py +++ b/tests/unit/test_formsemestre.py @@ -8,7 +8,6 @@ import pytest from tests.unit import yaml_setup, call_view import app -from app import db from app.models import Formation, FormSemestre from app.scodoc import ( sco_archives, @@ -18,6 +17,7 @@ from app.scodoc import ( sco_evaluations, sco_evaluation_check_abs, sco_evaluation_recap, + sco_formation_versions, sco_formsemestre_edit, sco_formsemestre_inscriptions, sco_formsemestre_status, @@ -52,7 +52,7 @@ def test_formsemestres_associate_new_version(test_client): assert {s.semestre_id for s in formsemestres} == {1} # Les rattache à une nouvelle version de la formation: formsemestre_ids = [s.id for s in formsemestres] - sco_formsemestre_edit.do_formsemestres_associate_new_version( + sco_formation_versions.do_formsemestres_associate_new_version( formation.id, formsemestre_ids ) new_formation: Formation = Formation.query.filter_by( @@ -107,7 +107,7 @@ def test_formsemestre_misc_views(test_client): assert isinstance(ans, (str, Response)) # ici str # Juste la page dialogue avant opération:: ans = sco_formsemestre_edit.formsemestre_clone(formsemestre.id) - ans = sco_formsemestre_edit.formsemestre_associate_new_version( + ans = sco_formation_versions.formsemestre_associate_new_version( formsemestre.formation_id, formsemestre.id ) ans = sco_formsemestre_edit.formsemestre_delete(formsemestre.id)