Edition UEs: assouplie verrouillage en BUT. + début modernisation code.

This commit is contained in:
Emmanuel Viennet 2024-10-17 23:44:54 +02:00
parent abd8bf484d
commit 3b984ea823
2 changed files with 134 additions and 144 deletions

View File

@ -45,7 +45,6 @@ from app.models import (
FormSemestreUECoef, FormSemestreUECoef,
Matiere, Matiere,
Module, Module,
ModuleImpl,
UniteEns, UniteEns,
) )
from app.models import ApcValidationRCUE, ScolarFormSemestreValidation, ScolarEvent from app.models import ApcValidationRCUE, ScolarFormSemestreValidation, ScolarEvent
@ -546,6 +545,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
formation_id, int(tf[2]["semestre_idx"]) formation_id, int(tf[2]["semestre_idx"])
) )
ue_id = do_ue_create(tf[2]) ue_id = do_ue_create(tf[2])
matiere_id = None
if is_apc or cursus.UE_IS_MODULE or tf[2]["create_matiere"]: if is_apc or cursus.UE_IS_MODULE or tf[2]["create_matiere"]:
# rappel: en APC, toutes les UE ont une matière, créée ici # rappel: en APC, toutes les UE ont une matière, créée ici
# (inutilisée mais à laquelle les modules sont rattachés) # (inutilisée mais à laquelle les modules sont rattachés)
@ -597,46 +597,21 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
) )
def _add_ue_semestre_id(ues: list[dict], is_apc): def next_ue_numero(formation_id, semestre_id=None) -> int:
"""ajoute semestre_id dans les ue, en regardant
semestre_idx ou à défaut, pour les formations non APC, le premier module
de chacune.
Les UE sans modules se voient attribuer le numero UE_SEM_DEFAULT (1000000),
qui les place à la fin de la liste.
"""
for ue in ues:
if ue["semestre_idx"] is not None:
ue["semestre_id"] = ue["semestre_idx"]
elif is_apc:
ue["semestre_id"] = codes_cursus.UE_SEM_DEFAULT
else:
# était le comportement ScoDoc7
ue = UniteEns.get_ue(ue["ue_id"])
module = ue.modules.first()
if module:
ue["semestre_id"] = module.semestre_id
else:
ue["semestre_id"] = codes_cursus.UE_SEM_DEFAULT
def next_ue_numero(formation_id, semestre_id=None):
"""Numero d'une nouvelle UE dans cette formation. """Numero d'une nouvelle UE dans cette formation.
Si le semestre est specifie, cherche les UE ayant des modules de ce semestre Si le semestre est specifie, cherche les UE ayant des modules de ce semestre
""" """
formation = db.session.get(Formation, formation_id) formation = db.session.get(Formation, formation_id)
ues = ue_list(args={"formation_id": formation_id}) ues = formation.ues.all()
if not ues: if not ues:
return 0 return 0
if semestre_id is None: if semestre_id is None:
return ues[-1]["numero"] + 1000 return ues[-1].numero + 1000
else:
# Avec semestre: (prend le semestre du 1er module de l'UE) # Avec semestre: (prend le semestre du 1er module de l'UE)
_add_ue_semestre_id(ues, formation.get_cursus().APC_SAE) ue_list_semestre = [ue for ue in ues if ue.get_semestre_id() == semestre_id]
ue_list_semestre = [ue for ue in ues if ue["semestre_id"] == semestre_id]
if ue_list_semestre: if ue_list_semestre:
return ue_list_semestre[-1]["numero"] + 10 return ue_list_semestre[-1].numero + 10
else: return ues[-1].numero + 1000
return ues[-1]["numero"] + 1000
def ue_delete(ue_id=None, delete_validations=False, dialog_confirmed=False): def ue_delete(ue_id=None, delete_validations=False, dialog_confirmed=False):
@ -698,20 +673,22 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""):
show_tags = scu.to_bool(request.args.get("show_tags", 0)) show_tags = scu.to_bool(request.args.get("show_tags", 0))
locked = formation.has_locked_sems(semestre_idx) locked = formation.has_locked_sems(semestre_idx)
semestre_ids = range(1, parcours.NB_SEM + 1) semestre_ids = range(1, parcours.NB_SEM + 1)
# transition: on requete ici via l'ORM mais on utilise les fonctions ScoDoc7
# basées sur des dicts ues = (
ues_obj = UniteEns.query.filter_by( formation.ues.filter_by(is_external=False)
formation_id=formation_id, is_external=False .order_by(UniteEns.semestre_idx, UniteEns.numero)
).order_by(UniteEns.semestre_idx, UniteEns.numero) .all()
)
# safety check: renumérote les ue s'il en manque ou s'il y a des ex-aequo. # safety check: renumérote les ue s'il en manque ou s'il y a des ex-aequo.
# cela facilite le travail de la passerelle ! # cela facilite le travail de la passerelle !
numeros = {ue.numero for ue in ues_obj} numeros = {ue.numero for ue in ues}
if (None in numeros) or len(numeros) < ues_obj.count(): if (None in numeros) or len(numeros) < len(ues):
scu.objects_renumber(db, ues_obj) scu.objects_renumber(db, ues)
ues_externes_obj = UniteEns.query.filter_by( ues_externes = UniteEns.query.filter_by(
formation_id=formation_id, is_external=True formation_id=formation_id, is_external=True
) ).all()
# liste ordonnée des formsemestres de cette formation: # liste ordonnée des formsemestres de cette formation:
formsemestres = sorted( formsemestres = sorted(
FormSemestre.query.filter_by(formation_id=formation_id).all(), FormSemestre.query.filter_by(formation_id=formation_id).all(),
@ -721,29 +698,25 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""):
if is_apc: if is_apc:
# Pour faciliter la transition des anciens programmes non APC # Pour faciliter la transition des anciens programmes non APC
for ue in ues_obj: for ue in ues:
ue.guess_semestre_idx() ue.guess_semestre_idx()
# vérifie qu'on a bien au moins une matière dans chaque UE # vérifie qu'on a bien au moins une matière dans chaque UE
if ue.matieres.count() < 1: if ue.matieres.count() < 1:
mat = Matiere(ue_id=ue.id) mat = Matiere(ue_id=ue.id)
db.session.add(mat) db.session.add(mat)
# donne des couleurs aux UEs crées avant # donne des couleurs aux UEs crées avant
colorie_anciennes_ues(ues_obj) colorie_anciennes_ues(ues)
db.session.commit() db.session.commit()
ues = [ue.to_dict() for ue in ues_obj]
ues_externes = [ue.to_dict() for ue in ues_externes_obj]
# tri par semestre et numero: # tri par semestre et numero:
_add_ue_semestre_id(ues, is_apc) ues.sort(key=lambda u: (u.get_semestre_id(), u.numero))
_add_ue_semestre_id(ues_externes, is_apc) ues_externes.sort(key=lambda u: (u.get_semestre_id(), u.numero))
ues.sort(key=lambda u: (u["semestre_id"], u["numero"]))
ues_externes.sort(key=lambda u: (u["semestre_id"], u["numero"]))
# Codes dupliqués (pour aider l'utilisateur) # Codes dupliqués (pour aider l'utilisateur)
seen = set() seen = set()
duplicated_codes = { duplicated_codes = {
ue["ue_code"] for ue in ues if ue["ue_code"] in seen or seen.add(ue["ue_code"]) ue.ue_code for ue in ues if ue.ue_code in seen or seen.add(ue.ue_code)
} }
ues_with_duplicated_code = [ue for ue in ues if ue["ue_code"] in duplicated_codes] ues_with_duplicated_code = [ue for ue in ues if ue.ue_code in duplicated_codes]
has_perm_change = current_user.has_permission(Permission.EditFormation) has_perm_change = current_user.has_permission(Permission.EditFormation)
# editable = (not locked) and has_perm_change # editable = (not locked) and has_perm_change
@ -799,8 +772,8 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
formation ont le même code : <tt>{ formation ont le même code : <tt>{
', '.join([ ', '.join([
'<a class="stdlink" href="' + url_for( "notes.ue_edit", '<a class="stdlink" href="' + url_for( "notes.ue_edit",
scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"] ) scodoc_dept=g.scodoc_dept, ue_id=ue.id )
+ '">' + ue["acronyme"] + " (code " + ue["ue_code"] + ")</a>" + '">' + ue.acronyme + " (code " + ue.ue_code + ")</a>"
for ue in ues_with_duplicated_code ]) for ue in ues_with_duplicated_code ])
}</tt>. }</tt>.
Il faut corriger cela, sinon les capitalisations et ECTS seront Il faut corriger cela, sinon les capitalisations et ECTS seront
@ -1115,7 +1088,7 @@ def _html_select_semestre_idx(formation_id, semestre_ids, semestre_idx):
def _ue_table_ues( def _ue_table_ues(
parcours, parcours,
ues: list[dict], ues: list[UniteEns],
editable, editable,
tag_editable, tag_editable,
has_perm_change, has_perm_change,
@ -1132,33 +1105,25 @@ def _ue_table_ues(
cur_ue_semestre_id = None cur_ue_semestre_id = None
iue = 0 iue = 0
for ue in ues: for ue in ues:
if ue["ects"] is None: ects_str = "" if ue.ects is None else f", {ue.ects:g} ECTS"
ue["ects_str"] = "" klass = "span_apo_edit" if editable else ""
else:
ue["ects_str"] = ", %g ECTS" % ue["ects"]
if editable:
klass = "span_apo_edit"
else:
klass = ""
edit_url = url_for( edit_url = url_for(
"apiweb.ue_set_code_apogee", "apiweb.ue_set_code_apogee",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
ue_id=ue["ue_id"], ue_id=ue.id,
) )
ue[ code_apogee_str = f""", Apo: <span
"code_apogee_str" class="{klass}" data-url="{edit_url}" id="{ue.id}"
] = f""", Apo: <span
class="{klass}" data-url="{edit_url}" id="{ue['ue_id']}"
data-placeholder="{scu.APO_MISSING_CODE_STR}">{ data-placeholder="{scu.APO_MISSING_CODE_STR}">{
ue["code_apogee"] or "" ue.code_apogee or ""
}</span>""" }</span>"""
if cur_ue_semestre_id != ue["semestre_id"]: if cur_ue_semestre_id != ue.semestre_id:
cur_ue_semestre_id = ue["semestre_id"] cur_ue_semestre_id = ue.semestre_id
if ue["semestre_id"] == codes_cursus.UE_SEM_DEFAULT: if ue.semestre_id == codes_cursus.UE_SEM_DEFAULT:
lab = "Pas d'indication de semestre:" lab = "Pas d'indication de semestre:"
else: else:
lab = f"""Semestre {ue["semestre_id"]}:""" lab = f"""Semestre {ue.semestre_id}:"""
H.append( H.append(
f'<div class="ue_list_div"><div class="ue_list_tit_sem">{lab}</div>' f'<div class="ue_list_div"><div class="ue_list_tit_sem">{lab}</div>'
) )
@ -1166,52 +1131,55 @@ def _ue_table_ues(
H.append('<li class="notes_ue_list">') H.append('<li class="notes_ue_list">')
if iue != 0 and editable: if iue != 0 and editable:
H.append( H.append(
'<a href="ue_move?ue_id=%s&after=0" class="aud">%s</a>' f"""<a href="{
% (ue["ue_id"], arrow_up) url_for( 'notes.ue_move',
scodoc_dept=g.scodoc_dept, ue_id=ue.id, after=0)}"
class="aud">{arrow_up}</a>"""
) )
else: else:
H.append(arrow_none) H.append(arrow_none)
if iue < len(ues) - 1 and editable: if iue < len(ues) - 1 and editable:
H.append( H.append(
'<a href="ue_move?ue_id=%s&after=1" class="aud">%s</a>' f"""<a href="{
% (ue["ue_id"], arrow_down) url_for( 'notes.ue_move',
scodoc_dept=g.scodoc_dept, ue_id=ue.id, after=1)}"
class="aud">{arrow_down}</a>"""
) )
else: else:
H.append(arrow_none) H.append(arrow_none)
ue["acro_titre"] = str(ue["acronyme"]) acro_titre = ue.acronyme
if ue["titre"] != ue["acronyme"]: if ue.titre != ue.acronyme:
ue["acro_titre"] += " " + str(ue["titre"]) acro_titre += " " + (ue.titre or "")
H.append( H.append(
"""%(acro_titre)s <span class="ue_code">(code %(ue_code)s%(ects_str)s, coef. %(coefficient)3.2f%(code_apogee_str)s)</span> f"""acro_titre <span class="ue_code">(code {ue.ue_code}{ects_str}, coef. {
(ue.coefficient or 0):3.2f}{code_apogee_str})</span>
<span class="ue_coef"></span> <span class="ue_coef"></span>
""" """
% ue
) )
if ue["type"] != codes_cursus.UE_STANDARD: if ue.type != codes_cursus.UE_STANDARD:
H.append( H.append(
'<span class="ue_type">%s</span>' f"""<span class="ue_type">{codes_cursus.UE_TYPE_NAME[ue.type]}</span>"""
% codes_cursus.UE_TYPE_NAME[ue["type"]]
) )
if ue["is_external"]: if ue.is_external:
# Cas spécial: si l'UE externe a plus d'un module, c'est peut être une UE # Cas spécial: si l'UE externe a plus d'un module, c'est peut être une UE
# qui a été déclarée externe par erreur (ou suite à un bug d'import/export xml) # qui a été déclarée externe par erreur (ou suite à un bug d'import/export xml)
# Dans ce cas, propose de changer le type (même si verrouillée) # Dans ce cas, propose de changer le type (même si verrouillée)
if len(sco_moduleimpl.moduleimpls_in_external_ue(ue["ue_id"])) > 1: if len(sco_moduleimpl.moduleimpls_in_external_ue(ue.id)) > 1:
H.append('<span class="ue_is_external">') H.append('<span class="ue_is_external">')
if has_perm_change: if has_perm_change:
H.append( H.append(
f"""<a class="stdlink" href="{ f"""<a class="stdlink" href="{
url_for("notes.ue_set_internal", url_for("notes.ue_set_internal",
scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"]) scodoc_dept=g.scodoc_dept, ue_id=ue.id)
}">transformer en UE ordinaire</a>&nbsp;""" }">transformer en UE ordinaire</a>&nbsp;"""
) )
H.append("</span>") H.append("</span>")
ue_locked, ue_locked_reason = ue_is_locked(ue["ue_id"]) ue_locked, ue_locked_reason = ue.is_locked()
ue_editable = editable and not ue_locked ue_editable = editable and not ue_locked
if ue_editable: if ue_editable:
H.append( H.append(
f"""<a class="stdlink" href="{ f"""<a class="stdlink" href="{
url_for("notes.ue_edit", scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"]) url_for("notes.ue_edit", scodoc_dept=g.scodoc_dept, ue_id=ue.id)
}">modifier</a>""" }">modifier</a>"""
) )
else: else:
@ -1231,11 +1199,14 @@ def _ue_table_ues(
delete_disabled_icon, delete_disabled_icon,
) )
) )
if (iue >= len(ues) - 1) or ue["semestre_id"] != ues[iue + 1]["semestre_id"]: if (iue >= len(ues) - 1) or (
ue.get_semestre_id() != ues[iue + 1].get_semestre_id()
):
H.append( H.append(
f"""</ul><ul><li><a href="{url_for('notes.ue_create', scodoc_dept=g.scodoc_dept, f"""</ul><ul><li><a href="{
formation_id=ue['formation_id'], semestre_idx=ue['semestre_id']) url_for('notes.ue_create', scodoc_dept=g.scodoc_dept,
}">Ajouter une UE dans le semestre {ue['semestre_id'] or ''}</a></li></ul> formation_id=ue.formation_id, semestre_idx=ue.semestre_id)
}">Ajouter une UE dans le semestre {ue.semestre_id or ''}</a></li></ul>
</div> </div>
""" """
) )
@ -1246,7 +1217,7 @@ def _ue_table_ues(
def _ue_table_matieres( def _ue_table_matieres(
parcours, parcours,
ue_dict: dict, ue: UniteEns,
editable, editable,
tag_editable, tag_editable,
arrow_up, arrow_up,
@ -1256,7 +1227,6 @@ def _ue_table_matieres(
delete_disabled_icon, delete_disabled_icon,
): ):
"""Édition de programme: liste des matières (et leurs modules) d'une UE.""" """Édition de programme: liste des matières (et leurs modules) d'une UE."""
ue = UniteEns.get_ue(ue_dict["ue_id"])
H = [] H = []
if not parcours.UE_IS_MODULE: if not parcours.UE_IS_MODULE:
H.append('<ul class="notes_matiere_list">') H.append('<ul class="notes_matiere_list">')
@ -1503,16 +1473,18 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False):
"edit an UE" "edit an UE"
# check # check
ue_id = args["ue_id"] ue_id = args["ue_id"]
ue = ue_list({"ue_id": ue_id})[0] ue = UniteEns.get_ue(ue_id)
if not bypass_lock: if not bypass_lock:
ue_locked, ue_locked_reason = ue_is_locked(ue["ue_id"]) ue_locked, ue_locked_reason = ue.is_locked()
if ue_locked: if ue_locked:
raise ScoLockedFormError(msg=f"UE verrouillée: {ue_locked_reason}") raise ScoLockedFormError(msg=f"UE verrouillée: {ue_locked_reason}")
# check: acronyme unique dans cette formation # check: acronyme unique dans cette formation
if "acronyme" in args: if "acronyme" in args:
new_acro = args["acronyme"] new_acro = args["acronyme"]
ues = ue_list({"formation_id": ue["formation_id"], "acronyme": new_acro}) ues = UniteEns.query.filter_by(
if ues and ues[0]["ue_id"] != ue_id: formation_id=ue.formation_id, acronyme=new_acro
).all()
if ues and ues[0].id != ue_id:
raise ScoValueError( raise ScoValueError(
f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé ! f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé !
(chaque UE doit avoir un acronyme unique dans la formation.)""" (chaque UE doit avoir un acronyme unique dans la formation.)"""
@ -1521,48 +1493,12 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False):
if "ue_code" in args and not args["ue_code"]: if "ue_code" in args and not args["ue_code"]:
del args["ue_code"] del args["ue_code"]
cnx = ndb.GetDBConnexion() ue.from_dict(args)
_ueEditor.edit(cnx, args) db.session.commit()
formation = db.session.get(Formation, ue["formation_id"])
if not dont_invalidate_cache: if not dont_invalidate_cache:
# Invalide les semestres utilisant cette formation # Invalide les semestres utilisant cette formation
# ainsi que les poids et coefs # ainsi que les poids et coefs
formation.invalidate_module_coefs() ue.formation.invalidate_module_coefs()
def ue_is_locked(ue_id: int) -> tuple[bool, str]:
"""True if UE should not be modified:
utilisée dans un formsemestre verrouillé ou validations de jury de cette UE.
Renvoie aussi une explication.
"""
# before 9.7.23: contains modules used in a locked formsemestre
# starting from 9.7.23: + existence de validations de jury de cette UE
ue = UniteEns.query.get(ue_id)
if not ue:
return True, "inexistante"
if ue.formation.is_apc():
# en APC, interdit toute modification d'UE si utilisée dans un semestre verrouillé
if False in [formsemestre.etat for formsemestre in ue.formation.formsemestres]:
return True, "utilisée dans un semestre verrouillé"
else:
# en classique: interdit si contient des modules utilisés dans des semestres verrouillés
# en effet, dans certaines (très anciennes) formations, une UE peut avoir des modules de
# différents semestre
if (
Module.query.filter(Module.ue_id == ue_id)
.join(Module.modimpls)
.join(ModuleImpl.formsemestre)
.filter_by(etat=False)
.count()
):
return True, "avec modules utilisés dans des semestres verrouillés"
nb_validations = ScolarFormSemestreValidation.query.filter_by(ue_id=ue_id).count()
if nb_validations > 0:
return True, f"avec {nb_validations} validations de jury"
return False, ""
UE_PALETTE = [ UE_PALETTE = [

View File

@ -10,6 +10,7 @@ from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
from app.models.but_refcomp import ApcNiveau, ApcParcours from app.models.but_refcomp import ApcNiveau, ApcParcours
from app.models.modules import Module from app.models.modules import Module
from app.scodoc import codes_cursus
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -185,10 +186,45 @@ class UniteEns(models.ScoDocModel):
return 1 if self.semestre_idx is None else (self.semestre_idx - 1) // 2 + 1 return 1 if self.semestre_idx is None else (self.semestre_idx - 1) // 2 + 1
def is_locked(self) -> tuple[bool, str]: def is_locked(self) -> tuple[bool, str]:
"""True if UE should not be modified""" """True if UE should not be modified:
from app.formations import edit_ue utilisée dans un formsemestre verrouillé ou validations de jury de cette UE.
Renvoie aussi une explication.
"""
from app.models import FormSemestre, ModuleImpl, ScolarFormSemestreValidation
return edit_ue.ue_is_locked(self.id) # before 9.7.23: contains modules used in a locked formsemestre
# starting from 9.7.23: + existence de validations de jury de cette UE
if self.formation.is_apc():
# en APC, interdit toute modification d'UE si il y a un formsemestre verrouillé
# de cette formation ayant le semestre de cette UE.
# (ne détaille pas les parcours, donc si un semestre Sn d'un parcours est verrouillé
# cela va verrouiller toutes les UE d'indice Sn, même si pas de ce parcours)
# modifié en 9.7.28
locked_sems = self.formation.formsemestres.filter_by(
etat=False, semestre_id=self.semestre_idx
)
if locked_sems.count():
return True, "utilisée dans un semestre verrouillé"
else:
# en classique: interdit si contient des modules utilisés dans des semestres verrouillés
# en effet, dans certaines (très anciennes) formations, une UE peut avoir des modules de
# différents semestre
if (
Module.query.filter(Module.ue_id == self.id)
.join(Module.modimpls)
.join(ModuleImpl.formsemestre)
.filter_by(etat=False)
.count()
):
return True, "avec modules utilisés dans des semestres verrouillés"
nb_validations = ScolarFormSemestreValidation.query.filter_by(
ue_id=self.id
).count()
if nb_validations > 0:
return True, f"avec {nb_validations} validations de jury"
return False, ""
def can_be_deleted(self) -> bool: def can_be_deleted(self) -> bool:
"""True si l'UE n'a pas de moduleimpl rattachés """True si l'UE n'a pas de moduleimpl rattachés
@ -214,6 +250,24 @@ class UniteEns(models.ScoDocModel):
db.session.commit() db.session.commit()
return self.semestre_idx return self.semestre_idx
def get_semestre_id(self) -> int:
"""L'indice du semestre de l'UE.
Regarde semestre_idx ou, pour les formations non APC,
le premier module de chacune.
Les UE sans modules se voient attribuer le numero UE_SEM_DEFAULT (1000000),
qui les place à la fin de la liste.
Contrairement à guess_semestre_idx, ne modifie pas l'UE.
"""
if self.semestre_idx is not None:
return self.semestre_idx
if self.formation.is_apc():
return codes_cursus.UE_SEM_DEFAULT
# était le comportement ScoDoc7
module = self.modules.first()
if module:
return module.semestre_id
return codes_cursus.UE_SEM_DEFAULT
def get_ects(self, parcour: ApcParcours = None, only_parcours=False) -> float: def get_ects(self, parcour: ApcParcours = None, only_parcours=False) -> float:
"""Crédits ECTS associés à cette UE. """Crédits ECTS associés à cette UE.
En BUT, cela peut quelquefois dépendre du parcours. En BUT, cela peut quelquefois dépendre du parcours.