diff --git a/app/__init__.py b/app/__init__.py
index db66bc4d..1c02b13f 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 00000000..4becfea5
--- /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 2ea75b66..1e89db7f 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 623753dd..ab8658e7 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 00000000..50d7c849
--- /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""""""
+ )
+ 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"""
+
+ """
+ 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 6d167afa..be7e348c 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 266e364c..999fd525 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""""""
- )
- 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 03d3ecc9..6626f10e 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 4dc782c9..0936b3d7 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 3e47f808..790aff3e 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):
{% 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 00000000..0b3820ca
--- /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 3de17f33..b33faf3e 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 38325274..046e078f 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 00000000..e445d884
--- /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 f2afe9a7..0247747d 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 981b114e..02945044 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)