diff --git a/app/__init__.py b/app/__init__.py index db66bc4de..1c02b13fe 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -88,6 +88,10 @@ def internal_server_error(exc): # note that we set the 500 status explicitly from app.scodoc import sco_utils as scu + # Invalide tous les caches + log("internal_server_error: clearing caches") + clear_scodoc_cache() + return ( render_template( "error_500.j2", 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_edit_ue.py b/app/scodoc/sco_edit_ue.py index 623753ddb..ab8658e79 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -928,7 +928,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours); H.append( f"""
  • Créer une nouvelle version de la formation (copie non verrouillée) diff --git a/app/scodoc/sco_formation_versions.py b/app/scodoc/sco_formation_versions.py new file mode 100644 index 000000000..50d7c849a --- /dev/null +++ b/app/scodoc/sco_formation_versions.py @@ -0,0 +1,333 @@ +# -*- 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, + ScolarEvent, + ScolarFormSemestreValidation, + UniteEns, +) + +from app.models.formations import Formation +from app.models.formsemestre import FormSemestre +import app.scodoc.sco_utils as scu + +from app import log +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc import sco_formations + + +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 nouvelle version + """ + formsemestre_id = int(formsemestre_id) if formsemestre_id else None + 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.

    +
    + """ + + ( + f""" +
    + Vous pouvez aussi essayer d'associer ce semestre à une autre formation identique. +
    + """ + if formsemestre_id is not None + else "" + ), + 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 semestres 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 !") + + # 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: + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + formsemestre.formation_id = formation_id + db.session.add(formsemestre) + _reassociate_moduleimpls(formsemestre, ues_old2new, modules_old2new) + + db.session.commit() + return formation_id + + +def _reassociate_moduleimpls( + formsemestre: FormSemestre, + ues_old2new: dict[int, int], + modules_old2new: dict[int, int], +): + """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: + for modimpl in formsemestre.modimpls: + modimpl.module_id = modules_old2new[modimpl.module_id] + db.session.add(modimpl) + # 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) + + # update decisions: + for event in ScolarEvent.query.filter_by(formsemestre_id=formsemestre.id): + if event.ue_id is not None: + event.ue_id = ues_old2new[event.ue_id] + db.session.add(event) + + for validation in ScolarFormSemestreValidation.query.filter_by( + formsemestre_id=formsemestre.id + ): + if validation.ue_id is not None: + validation.ue_id = ues_old2new[validation.ue_id] + db.session.add(validation) + + db.session.commit() + + +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 et les UEs + modules_old2new = {} + 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}" + ) + modules_old2new[old_module.id] = new_module.id + + ues_old2new = {} + for old_ue in formsemestre.formation.ues: + new_ue: UniteEns = UniteEns.query.filter_by( + formation_id=new_formation.id, acronyme=old_ue.acronyme, titre=old_ue.titre + ).first() + if new_ue is None: + raise ValueError(f"formsemestre_change_formation: erreur sur UE {old_ue}") + ues_old2new[old_ue.id] = new_ue.id + + formsemestre.formation = new_formation + db.session.add(formsemestre) + _reassociate_moduleimpls(formsemestre, ues_old2new, modules_old2new) + + 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/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 03d3ecc91..6626f10e0 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -349,7 +349,7 @@ SCO_USER_MANUAL = "https://scodoc.org/GuideUtilisateur" SCO_ANNONCES_WEBSITE = "https://listes.univ-paris13.fr/mailman/listinfo/scodoc-annonces" SCO_DEVEL_LIST = "scodoc-devel@listes.univ-paris13.fr" SCO_USERS_LIST = "notes@listes.univ-paris13.fr" -SCO_LISTS_URL = "https://scodoc.org/ListesDeDiffusion/" +SCO_LISTS_URL = "https://scodoc.org/Contact" SCO_DISCORD_ASSISTANCE = "https://discord.gg/ybw6ugtFsZ" # Mails avec exceptions (erreurs) anormales envoyés à cette adresse: diff --git a/app/static/js/saisie_notes.js b/app/static/js/saisie_notes.js index 4dc782c93..0936b3d7a 100644 --- a/app/static/js/saisie_notes.js +++ b/app/static/js/saisie_notes.js @@ -5,7 +5,7 @@ $().ready(function () { $("#formnotes .note").bind("blur", valid_note); $("#formnotes input").bind("paste", paste_text); - $(".btn_masquer_DEM").bind("click", masquer_DEM); + $(".btn_masquer_DEM").bind("click", masquer_DEM); }); @@ -16,7 +16,7 @@ function is_valid_note(v) { var note_min = parseFloat($("#eval_note_min").text()); var note_max = parseFloat($("#eval_note_max").text()); - if (!v.match("^-?[0-9.]*$")) { + if (!v.match("^-?[0-9]*.?[0-9]*$")) { return (v == "ABS") || (v == "EXC") || (v == "SUPR") || (v == "ATT") || (v == "DEM"); } else { var x = parseFloat(v); @@ -51,9 +51,9 @@ function save_note(elem, v, etudid) { 'comment': document.getElementById('formnotes_comment').value }, function (result) { - sco_message("enregistré"); - elem.className = "note_saved"; if (result['nbchanged'] > 0) { + sco_message("enregistré"); + elem.className = "note_saved"; // il y avait une decision de jury ? if (result.existing_decisions[0] == etudid) { if (v != $(elem).attr('data-orig-value')) { @@ -66,8 +66,12 @@ function save_note(elem, v, etudid) { if (result['history_menu']) { $("#hist_" + etudid).html(result['history_menu']); } + $(elem).attr('data-last-saved-value', v); + } else { + $('#sco_msg').html("").show(); + sco_message("valeur non enregistrée"); } - $(elem).attr('data-last-saved-value', v) + } ); } @@ -93,7 +97,7 @@ function paste_text(e) { var data = clipb.getData('Text'); var list = data.split(/\r\n|\r|\n|\t| /g); var currentInput = event.currentTarget; - var masquerDEM = document.querySelector("body").classList.contains("masquer_DEM"); + var masquerDEM = document.querySelector("body").classList.contains("masquer_DEM"); for (var i = 0; i < list.length; i++) { currentInput.value = list[i]; @@ -102,14 +106,14 @@ function paste_text(e) { currentInput.dispatchEvent(evt); var sibbling = currentInput.parentElement.parentElement.nextElementSibling; while ( - sibbling && - ( - sibbling.style.display == "none" || - ( - masquerDEM && sibbling.classList.contains("etud_dem") - ) - ) - ) { + sibbling && + ( + sibbling.style.display == "none" || + ( + masquerDEM && sibbling.classList.contains("etud_dem") + ) + ) + ) { sibbling = sibbling.nextElementSibling; } if (sibbling) { @@ -123,6 +127,6 @@ function paste_text(e) { } } -function masquer_DEM(){ - document.querySelector("body").classList.toggle("masquer_DEM"); +function masquer_DEM() { + document.querySelector("body").classList.toggle("masquer_DEM"); } diff --git a/app/templates/error_500.j2 b/app/templates/error_500.j2 index 3e47f8087..790aff3e1 100644 --- a/app/templates/error_500.j2 +++ b/app/templates/error_500.j2 @@ -16,8 +16,9 @@ }}">le canal Discord.

    {% if 'scodoc_dept' in g %} -

    Pour aider les développeurs à corriger le problème, nous vous - suggérons d'envoyer les données anonymisées sur votre configuration: +

    Pour aider à corriger le problème, nous vous + remercions d'envoyer ce rapport d'erreur + (qui contient des données anonymisées sur votre configuration):

    @@ -29,10 +30,7 @@

    {% endif %} -

    Vous pouvez aussi envoyer un mail à la liste "notes" - {{scu.SCO_USERS_LIST}} -

    -

    Pour plus d'informations sur les listes de diffusion +

    Vous pouvez aussi contacter l'équipe de développeurs: voir cette page.

    diff --git a/app/templates/formsemestre/change_formation.j2 b/app/templates/formsemestre/change_formation.j2 new file mode 100644 index 000000000..0b3820cae --- /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..e445d8843 --- /dev/null +++ b/app/views/notes_formsemestre.py @@ -0,0 +1,107 @@ +# -*- 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 +""" + +from flask import flash, redirect, render_template, url_for +from flask import g, request + +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..0247747dd 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.70" 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)