1
0
forked from ScoDoc/ScoDoc

Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into table

This commit is contained in:
Emmanuel Viennet 2023-03-23 10:44:12 +01:00
commit 8d4cf28e50
16 changed files with 633 additions and 255 deletions

View File

@ -88,6 +88,10 @@ def internal_server_error(exc):
# note that we set the 500 status explicitly # note that we set the 500 status explicitly
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
# Invalide tous les caches
log("internal_server_error: clearing caches")
clear_scodoc_cache()
return ( return (
render_template( render_template(
"error_500.j2", "error_500.j2",

View File

@ -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()

View File

@ -221,6 +221,10 @@ class Module(db.Model):
"""returns { ue_id : coef }""" """returns { ue_id : coef }"""
return {p.ue.id: p.coef for p in self.ue_coefs} 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): def delete_ue_coef(self, ue):
"""delete coef""" """delete coef"""
if self.formation.has_locked_sems(self.ue.semestre_idx): if self.formation.has_locked_sems(self.ue.semestre_idx):

View File

@ -928,7 +928,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
H.append( H.append(
f""" f"""
<li><a class="stdlink" href="{ <li><a class="stdlink" href="{
url_for('notes.formsemestre_associate_new_version', url_for('notes.formsemestre_associate_new_version',
scodoc_dept=g.scodoc_dept, formation_id=formation_id scodoc_dept=g.scodoc_dept, formation_id=formation_id
) )
}">Créer une nouvelle version de la formation</a> (copie non verrouillée) }">Créer une nouvelle version de la formation</a> (copie non verrouillée)

View File

@ -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"""<div><input type="checkbox" name="other_formsemestre_ids:list"
value="{other_formsemestre.id}" {checked} {disabled}
><a class="stdlink" href="{
url_for("notes.formsemestre_status",
scodoc_dept=g.scodoc_dept, formsemestre_id=other_formsemestre.id)
}">{other_formsemestre.titre_mois()}</a></input></div>"""
)
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(
(
"""<h2>Associer à une nouvelle version de formation non verrouillée ?</h2>"""
if formsemestre_id
else """<h2>Créer une nouvelle version de la formation ?</h2>"""
)
+ f"""<p><b>Formation: </b><a class="stdlink" href="{
url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id)
}">{formation.titre} version {formation.version}</a></p>
<p class="help">Le programme pédagogique ("formation") va être dupliqué
pour que vous puissiez le modifier sans affecter les semestres déjà terminés.
</p>
<p class="help">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...).
</p>
<div class="othersemlist">
<p>Si vous voulez associer des semestres à la nouvelle
version, cochez-les maintenant <br>
(<b>attention&nbsp;: vous ne pourrez pas le faire plus tard car on ne peut pas
changer la formation d'un semestre !</b>):
</p>
{ "".join(H) }
<p>Les données (étudiants, notes...) de ces semestres seront inchangées.</p>
</div>
"""
+ (
f"""
<div class="othersemlist">
Vous pouvez aussi essayer d'<a class="stdlink" href="{url_for(
"notes.formsemestre_change_formation",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id
)}
">associer ce semestre à une autre formation identique</a>.
</div>
"""
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 -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()

View File

@ -27,11 +27,10 @@
"""Import / Export de formations """Import / Export de formations
""" """
from operator import itemgetter
import xml.dom.minidom import xml.dom.minidom
import flask import flask
from flask import flash, g, url_for, request from flask import flash, g, url_for
from flask_login import current_user from flask_login import current_user
import app.scodoc.sco_utils as scu 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_matiere
from app.scodoc import sco_edit_module from app.scodoc import sco_edit_module
from app.scodoc import sco_edit_ue from app.scodoc import sco_edit_ue
from app.scodoc import sco_formsemestre
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc import sco_tag_module from app.scodoc import sco_tag_module
from app.scodoc import sco_xml from app.scodoc import sco_xml
@ -95,18 +93,19 @@ def formation_list(formation_id=None, args={}): ### XXX obsolete, à supprimer
return r return r
def formation_export( def formation_export_dict(
formation_id, formation: Formation,
export_ids=False, export_ids=False,
export_tags=True, export_tags=True,
export_external_ues=False, export_external_ues=False,
export_codes_apo=True, export_codes_apo=True,
format=None, ac_as_list=False,
): ue_reference_style="id",
"""Get a formation, with UE, matieres, modules ) -> dict:
in desired format """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) f_dict = formation.to_dict(with_refcomp_attrs=True)
if not export_ids: if not export_ids:
del f_dict["id"] del f_dict["id"]
@ -131,7 +130,8 @@ def formation_export(
# Et le parcour: # Et le parcour:
if ue.parcour: if ue.parcour:
ue_dict["parcour"] = [ue.parcour.to_dict(with_annees=False)] 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: if not export_ids:
for id_id in ( for id_id in (
"id", "id",
@ -165,19 +165,28 @@ def formation_export(
if tags: if tags:
mod["tags"] = [{"name": x} for x in 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(): if module.is_apc():
# Exporte les coefficients # Exporte les coefficients
mod["coefficients"] = [ if ue_reference_style == "id":
{"ue_reference": str(ue_id), "coef": str(coef)} mod["coefficients"] = [
for (ue_id, coef) in module.get_ue_coef_dict().items() {"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 # Et les parcours
mod["parcours"] = [ mod["parcours"] = [
p.to_dict(with_annees=False) for p in module.parcours p.to_dict(with_annees=False) for p in module.parcours
] ]
# Et les AC # Et les AC
if format == "xml": if ac_as_list:
# XML préfère une liste # XML préfère une liste
mod["app_critiques"] = [ mod["app_critiques"] = [
x.to_dict(with_code=True) for x in module.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"] del mod["code_apogee"]
if mod["ects"] is None: if mod["ects"] is None:
del mod["ects"] 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}" filename = f"scodoc_formation_{formation.departement.acronym}_{formation.acronyme or ''}_v{formation.version}"
return scu.sendResult( return scu.sendResult(
f_dict, f_dict,
@ -597,5 +628,4 @@ def formation_create_new_version(formation_id, redirect=True):
msg="Nouvelle version !", msg="Nouvelle version !",
) )
) )
else: return new_id, modules_old2new, ues_old2new
return new_id, modules_old2new, ues_old2new

View File

@ -39,7 +39,6 @@ from app.models import (
Module, Module,
ModuleImpl, ModuleImpl,
Evaluation, Evaluation,
EvaluationUEPoids,
UniteEns, UniteEns,
ScolarFormSemestreValidation, ScolarFormSemestreValidation,
ScolarAutorisationInscription, ScolarAutorisationInscription,
@ -64,14 +63,11 @@ from app.scodoc import codes_cursus
from app.scodoc import sco_compute_moy from app.scodoc import sco_compute_moy
from app.scodoc import sco_edit_module from app.scodoc import sco_edit_module
from app.scodoc import sco_edit_ue 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_evaluation_db
from app.scodoc import sco_formations
from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups_copy from app.scodoc import sco_groups_copy
from app.scodoc import sco_modalites from app.scodoc import sco_modalites
from app.scodoc import sco_moduleimpl 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_permissions_check
from app.scodoc import sco_portal_apogee from app.scodoc import sco_portal_apogee
from app.scodoc import sco_preferences 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. """Clone a semestre: make copy, same modules, same options, same resps, same partitions.
New dates, responsable_id 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( formsemestre_orig: FormSemestre = FormSemestre.query.get_or_404(
orig_formsemestre_id orig_formsemestre_id
) )
@ -1291,207 +1287,6 @@ def do_formsemestre_clone(
return formsemestre_id 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"""<div><input type="checkbox" name="other_formsemestre_ids:list"
value="{other_formsemestre.id}" {checked} {disabled}
><a class="stdlink" href="{
url_for("notes.formsemestre_status",
scodoc_dept=g.scodoc_dept, formsemestre_id=other_formsemestre.id)
}">{other_formsemestre.titre_mois()}</a></input></div>"""
)
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(
(
"""<h2>Associer à une nouvelle version de formation non verrouillée ?</h2>"""
if formsemestre_id
else """<h2>Créer une nouvelle version de la formation ?</h2>"""
)
+ f"""<p><b>Formation: </b><a class="stdlink" href="{
url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id)
}">{formation.titre} version {formation.version}</a></p>
<p class="help">Le programme pédagogique ("formation") va être dupliqué
pour que vous puissiez le modifier sans affecter les semestres déjà terminés.
</p>
<p class="help">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...).
</p>
<div class="othersemlist">
<p>Si vous voulez associer des semestres à la nouvelle
version, cochez-les maintenant <br>
(<b>attention&nbsp;: vous ne pourrez pas le faire plus tard car on ne peut pas
changer la formation d'un semestre !</b>):
</p>"""
+ "".join(H)
+ """<p>Les données (étudiants, notes...) de ces semestres seront inchangées.</p>"""
+ "</div>",
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 -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): def formsemestre_delete(formsemestre_id):
"""Delete a formsemestre (affiche avertissements)""" """Delete a formsemestre (affiche avertissements)"""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)

View File

@ -349,7 +349,7 @@ SCO_USER_MANUAL = "https://scodoc.org/GuideUtilisateur"
SCO_ANNONCES_WEBSITE = "https://listes.univ-paris13.fr/mailman/listinfo/scodoc-annonces" SCO_ANNONCES_WEBSITE = "https://listes.univ-paris13.fr/mailman/listinfo/scodoc-annonces"
SCO_DEVEL_LIST = "scodoc-devel@listes.univ-paris13.fr" SCO_DEVEL_LIST = "scodoc-devel@listes.univ-paris13.fr"
SCO_USERS_LIST = "notes@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" SCO_DISCORD_ASSISTANCE = "https://discord.gg/ybw6ugtFsZ"
# Mails avec exceptions (erreurs) anormales envoyés à cette adresse: # Mails avec exceptions (erreurs) anormales envoyés à cette adresse:

View File

@ -5,7 +5,7 @@ $().ready(function () {
$("#formnotes .note").bind("blur", valid_note); $("#formnotes .note").bind("blur", valid_note);
$("#formnotes input").bind("paste", paste_text); $("#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_min = parseFloat($("#eval_note_min").text());
var note_max = parseFloat($("#eval_note_max").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"); return (v == "ABS") || (v == "EXC") || (v == "SUPR") || (v == "ATT") || (v == "DEM");
} else { } else {
var x = parseFloat(v); var x = parseFloat(v);
@ -51,9 +51,9 @@ function save_note(elem, v, etudid) {
'comment': document.getElementById('formnotes_comment').value 'comment': document.getElementById('formnotes_comment').value
}, },
function (result) { function (result) {
sco_message("enregistré");
elem.className = "note_saved";
if (result['nbchanged'] > 0) { if (result['nbchanged'] > 0) {
sco_message("enregistré");
elem.className = "note_saved";
// il y avait une decision de jury ? // il y avait une decision de jury ?
if (result.existing_decisions[0] == etudid) { if (result.existing_decisions[0] == etudid) {
if (v != $(elem).attr('data-orig-value')) { if (v != $(elem).attr('data-orig-value')) {
@ -66,8 +66,12 @@ function save_note(elem, v, etudid) {
if (result['history_menu']) { if (result['history_menu']) {
$("#hist_" + etudid).html(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 data = clipb.getData('Text');
var list = data.split(/\r\n|\r|\n|\t| /g); var list = data.split(/\r\n|\r|\n|\t| /g);
var currentInput = event.currentTarget; 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++) { for (var i = 0; i < list.length; i++) {
currentInput.value = list[i]; currentInput.value = list[i];
@ -102,14 +106,14 @@ function paste_text(e) {
currentInput.dispatchEvent(evt); currentInput.dispatchEvent(evt);
var sibbling = currentInput.parentElement.parentElement.nextElementSibling; var sibbling = currentInput.parentElement.parentElement.nextElementSibling;
while ( while (
sibbling && sibbling &&
( (
sibbling.style.display == "none" || sibbling.style.display == "none" ||
( (
masquerDEM && sibbling.classList.contains("etud_dem") masquerDEM && sibbling.classList.contains("etud_dem")
) )
) )
) { ) {
sibbling = sibbling.nextElementSibling; sibbling = sibbling.nextElementSibling;
} }
if (sibbling) { if (sibbling) {
@ -123,6 +127,6 @@ function paste_text(e) {
} }
} }
function masquer_DEM(){ function masquer_DEM() {
document.querySelector("body").classList.toggle("masquer_DEM"); document.querySelector("body").classList.toggle("masquer_DEM");
} }

View File

@ -16,8 +16,9 @@
}}">le canal Discord</a></b>. }}">le canal Discord</a></b>.
</p> </p>
{% if 'scodoc_dept' in g %} {% if 'scodoc_dept' in g %}
<p>Pour aider les développeurs à corriger le problème, nous vous <p>Pour aider à corriger le problème, nous vous
suggérons d'envoyer les données anonymisées sur votre configuration: <b>remercions d'envoyer ce rapport d'erreur</b>
(qui contient des données anonymisées sur votre configuration):
<form method="POST" action="{{ url_for( 'scolar.sco_dump_and_send_db', <form method="POST" action="{{ url_for( 'scolar.sco_dump_and_send_db',
scodoc_dept=g.scodoc_dept ) }}"> scodoc_dept=g.scodoc_dept ) }}">
<input type="hidden" name="request_url" value="{{request_url}}"> <input type="hidden" name="request_url" value="{{request_url}}">
@ -29,10 +30,7 @@
</form> </form>
</p> </p>
{% endif %} {% endif %}
<p>Vous pouvez aussi envoyer un mail à la liste "notes" <p>Vous pouvez aussi contacter l'équipe de développeurs:
<a href="mailto:{{scu.SCO_USERS_LIST}}">{{scu.SCO_USERS_LIST}}</a>
</p>
<p>Pour plus d'informations sur les listes de diffusion
<a href="{{ scu.SCO_LISTS_URL }}">voir cette page</a>. <a href="{{ scu.SCO_LISTS_URL }}">voir cette page</a>.
</p> </p>

View File

@ -0,0 +1,30 @@
{% extends "sco_page.j2" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block styles %}
{{super()}}
{% endblock %}
{% block app_content %}
<div class="tab-content">
<h2>Changement de la formation du semestre</h2>
<p class="help"> On ne peut pas changer la formation d'un semestre existant car
elle défini son organisation (modules, ...), SAUF si la nouvelle formation a
<em>exactement</em> 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.
</p>
{% if formations %}
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
</div>
{% else %}
<div class="fontred">Aucune formation ne peut se substituer à celle de ce semestre.</div>
{% endif %}
</div>
{% endblock %}

View File

@ -105,4 +105,13 @@ class ScoData:
self.prefs = sco_preferences.SemPreferences(formsemestre_id) 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,
)

View File

@ -109,6 +109,7 @@ from app.scodoc import sco_evaluation_recap
from app.scodoc import sco_export_results from app.scodoc import sco_export_results
from app.scodoc import sco_formations from app.scodoc import sco_formations
from app.scodoc import sco_formation_recap 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
from app.scodoc import sco_formsemestre_custommenu from app.scodoc import sco_formsemestre_custommenu
from app.scodoc import sco_formsemestre_edit from app.scodoc import sco_formsemestre_edit
@ -188,7 +189,7 @@ sco_publish(
) )
sco_publish( sco_publish(
"/formsemestre_associate_new_version", "/formsemestre_associate_new_version",
sco_formsemestre_edit.formsemestre_associate_new_version, sco_formation_versions.formsemestre_associate_new_version,
Permission.ScoChangeFormation, Permission.ScoChangeFormation,
methods=["GET", "POST"], methods=["GET", "POST"],
) )

View File

@ -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/<int:formsemestre_id>", 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),
)

View File

@ -1,7 +1,7 @@
# -*- mode: python -*- # -*- mode: python -*-
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
SCOVERSION = "9.4.68" SCOVERSION = "9.4.70"
SCONAME = "ScoDoc" SCONAME = "ScoDoc"

View File

@ -8,7 +8,6 @@ import pytest
from tests.unit import yaml_setup, call_view from tests.unit import yaml_setup, call_view
import app import app
from app import db
from app.models import Formation, FormSemestre from app.models import Formation, FormSemestre
from app.scodoc import ( from app.scodoc import (
sco_archives, sco_archives,
@ -18,6 +17,7 @@ from app.scodoc import (
sco_evaluations, sco_evaluations,
sco_evaluation_check_abs, sco_evaluation_check_abs,
sco_evaluation_recap, sco_evaluation_recap,
sco_formation_versions,
sco_formsemestre_edit, sco_formsemestre_edit,
sco_formsemestre_inscriptions, sco_formsemestre_inscriptions,
sco_formsemestre_status, sco_formsemestre_status,
@ -52,7 +52,7 @@ def test_formsemestres_associate_new_version(test_client):
assert {s.semestre_id for s in formsemestres} == {1} assert {s.semestre_id for s in formsemestres} == {1}
# Les rattache à une nouvelle version de la formation: # Les rattache à une nouvelle version de la formation:
formsemestre_ids = [s.id for s in formsemestres] 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 formation.id, formsemestre_ids
) )
new_formation: Formation = Formation.query.filter_by( 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 assert isinstance(ans, (str, Response)) # ici str
# Juste la page dialogue avant opération:: # Juste la page dialogue avant opération::
ans = sco_formsemestre_edit.formsemestre_clone(formsemestre.id) 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 formsemestre.formation_id, formsemestre.id
) )
ans = sco_formsemestre_edit.formsemestre_delete(formsemestre.id) ans = sco_formsemestre_edit.formsemestre_delete(formsemestre.id)