ScoDoc/app/scodoc/sco_edit_ue.py
2024-08-15 17:06:07 +02:00

1575 lines
55 KiB
Python

# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 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
#
##############################################################################
"""Ajout/Modification/Suppression UE
"""
import re
import sqlalchemy as sa
import flask
from flask import flash, render_template, url_for
from flask import g, request
from flask_login import current_user
from app import db, log
from app.but import apc_edit_ue
from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN
from app.models import (
Formation,
FormSemestre,
FormSemestreUEComputationExpr,
FormSemestreUECoef,
Matiere,
UniteEns,
)
from app.models import ApcValidationRCUE, ScolarFormSemestreValidation, ScolarEvent
from app.models import ScolarNews
import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import (
ScoValueError,
ScoLockedFormError,
ScoNonEmptyFormationObject,
)
from app.scodoc import html_sco_header
from app.scodoc import codes_cursus
from app.scodoc import sco_edit_apc
from app.scodoc import sco_edit_matiere
from app.scodoc import sco_edit_module
from app.scodoc import sco_groups
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_tag_module
_ueEditor = ndb.EditableTable(
"notes_ue",
"ue_id",
(
"ue_id",
"formation_id",
"acronyme",
"numero",
"titre",
"semestre_idx",
"type",
"ue_code",
"ects",
"is_external",
"code_apogee",
"code_apogee_rcue",
"coefficient",
"coef_rcue",
"color",
"niveau_competence_id",
),
convert_empty_to_nulls=False, # necessaire pour ue_code == ""
sortkey="numero",
input_formators={
"type": ndb.int_null_is_zero,
"is_external": scu.to_bool,
"ects": ndb.float_null_is_null,
},
output_formators={
"numero": ndb.int_null_is_zero,
"ects": ndb.float_null_is_null,
"coefficient": ndb.float_null_is_zero,
"semestre_idx": ndb.int_null_is_null,
},
)
def ue_list(*args, **kw):
"list UEs"
cnx = ndb.GetDBConnexion()
return _ueEditor.list(cnx, *args, **kw)
def do_ue_create(args, allow_empty_ue_code=False):
"create an ue"
cnx = ndb.GetDBConnexion()
# check duplicates
ues = ue_list({"formation_id": args["formation_id"], "acronyme": args["acronyme"]})
if ues:
raise ScoValueError(
f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé !
(chaque UE doit avoir un acronyme unique dans la formation)"""
)
if "ue_code" not in args or args["ue_code"] is None or not args["ue_code"].strip():
if allow_empty_ue_code:
args["ue_code"] = ""
else:
# évite les conflits: génère nouveau ue_code
while True:
cursor = db.session.execute(sa.text("select notes_newid_ucod();"))
code = cursor.fetchone()[0]
if UniteEns.query.filter_by(ue_code=code).count() == 0:
break
args["ue_code"] = code
# last checks
if not args.get("acronyme"):
raise ScoValueError("acronyme vide")
args["coefficient"] = args.get("coefficient", None)
if args["coefficient"] == "":
args["coefficient"] = None
# create
# XXX TODO utiliser UniteEns.create_from_dict
ue_id = _ueEditor.create(cnx, args)
log(f"do_ue_create: created {ue_id} with {args}")
formation: Formation = db.session.get(Formation, args["formation_id"])
formation.invalidate_module_coefs()
# news
formation = db.session.get(Formation, args["formation_id"])
ScolarNews.add(
typ=ScolarNews.NEWS_FORM,
obj=args["formation_id"],
text=f"Modification de la formation {formation.acronyme}",
)
formation.invalidate_cached_sems()
return ue_id
def do_ue_delete(ue: UniteEns, delete_validations=False, force=False):
"""delete UE and attached matieres (but not modules).
Si force, pas de confirmation dialog et pas de redirect
"""
formation: Formation = ue.formation
semestre_idx = ue.semestre_idx
if not ue.can_be_deleted():
raise ScoNonEmptyFormationObject(
f"UE (id={ue.id}, dud)",
msg=f"{ue.titre or ''} ({ue.acronyme})",
dest_url=url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
formation_id=formation.id,
semestre_idx=semestre_idx,
),
)
log(f"do_ue_delete: ue_id={ue.id}, delete_validations={delete_validations}")
# Il y a-t-il des etudiants ayant validé cette UE ?
# si oui, propose de supprimer les validations
validations_ue = ScolarFormSemestreValidation.query.filter_by(ue_id=ue.id).all()
validations_rcue = ApcValidationRCUE.query.filter(
(ApcValidationRCUE.ue1_id == ue.id) | (ApcValidationRCUE.ue2_id == ue.id)
).all()
if (
(len(validations_ue) > 0 or len(validations_rcue) > 0)
and not delete_validations
and not force
):
return scu.confirm_dialog(
f"""<p>Des étudiants ont une décision de jury sur l'UE {ue.acronyme} ({ue.titre})</p>
<p>Si vous supprimez cette UE, ces décisions vont être supprimées !</p>""",
dest_url="",
target_variable="delete_validations",
cancel_url=url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
formation_id=formation.id,
semestre_idx=semestre_idx,
),
parameters={"ue_id": ue.id, "dialog_confirmed": 1},
)
if delete_validations:
log(f"deleting all validations of UE {ue.id}")
for v in validations_ue:
db.session.delete(v)
for v in validations_rcue:
db.session.delete(v)
# delete old formulas
formulas = FormSemestreUEComputationExpr.query.filter_by(ue_id=ue.id).all()
for formula in formulas:
db.session.delete(formula)
# delete all matieres in this UE
for mat in Matiere.query.filter_by(ue_id=ue.id):
db.session.delete(mat)
# delete uecoefs
for uecoef in FormSemestreUECoef.query.filter_by(ue_id=ue.id):
db.session.delete(uecoef)
# delete events
for event in ScolarEvent.query.filter_by(ue_id=ue.id):
db.session.delete(event)
db.session.flush()
db.session.delete(ue)
db.session.commit()
# cas compliqué, mais rarement utilisé: acceptable de tout invalider
formation.invalidate_module_coefs()
# -> invalide aussi les formsemestres
# news
ScolarNews.add(
typ=ScolarNews.NEWS_FORM,
obj=formation.id,
text=f"Modification de la formation {formation.acronyme}",
)
#
if not force:
return flask.redirect(
url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
formation_id=formation.id,
semestre_idx=semestre_idx,
)
)
return None
def ue_create(formation_id=None, default_semestre_idx=None):
"""Formulaire création d'une UE"""
return ue_edit(
create=True,
formation_id=formation_id,
default_semestre_idx=default_semestre_idx,
)
def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=None):
"""Formulaire modification ou création d'une UE"""
create = int(create)
if not create:
ue: UniteEns = UniteEns.query.get_or_404(ue_id)
ue_dict = ue.to_dict()
formation_id = ue.formation_id
title = f"Modification de l'UE {ue.acronyme} {ue.titre}"
initvalues = ue_dict
submitlabel = "Modifier les valeurs"
can_change_semestre_id = (
(ue.modules.count() == 0) or (ue.semestre_idx is None)
) and ue.niveau_competence is None
else:
ue = None
title = "Création d'une UE"
exp = re.compile(r"UCOD(\d+)$")
matches = {exp.match(u.ue_code) for u in UniteEns.query if exp.match(u.ue_code)}
max_code = (
max(int(match.group(1)) for match in matches if match) if matches else 0
)
proposed_code = f"UCOD{max_code+1}"
initvalues = {
"semestre_idx": default_semestre_idx,
"color": ue_guess_color_default(formation_id, default_semestre_idx),
"coef_rcue": 1.0,
"ue_code": proposed_code,
}
submitlabel = "Créer cette UE"
can_change_semestre_id = True
formation = db.session.get(Formation, formation_id)
if not formation:
raise ScoValueError(f"Formation inexistante ! (id={formation_id})")
cursus = formation.get_cursus()
is_apc = cursus.APC_SAE
semestres_indices = list(range(1, cursus.NB_SEM + 1))
ue_types = cursus.ALLOWED_UE_TYPES
ue_types.sort()
ue_types_names = [codes_cursus.UE_TYPE_NAME[k] for k in ue_types]
ue_types = [str(x) for x in ue_types]
form_descr = [
("ue_id", {"input_type": "hidden"}),
("create", {"input_type": "hidden", "default": create}),
("formation_id", {"input_type": "hidden", "default": formation_id}),
("titre", {"size": 48, "explanation": "nom de l'UE"}),
("acronyme", {"size": 12, "explanation": "abbréviation", "allow_null": False}),
(
"numero",
{
"size": 4,
"explanation": "numéro (1,2,3,4) de l'UE pour l'ordre d'affichage",
"type": "int",
},
),
]
if can_change_semestre_id:
form_descr += [
(
"semestre_idx",
{
"input_type": "menu",
"type": "int",
"allow_null": False,
"title": cursus.SESSION_NAME.capitalize(),
"explanation": f"{cursus.SESSION_NAME} de l'UE dans la formation",
"labels": ["non spécifié"] + [str(x) for x in semestres_indices],
"allowed_values": [""] + semestres_indices,
},
),
]
else:
form_descr += [
("semestre_idx", {"default": ue.semestre_idx, "input_type": "hidden"}),
]
form_descr += [
(
"type",
{
"explanation": "type d'UE",
"input_type": "menu",
"allowed_values": ue_types,
"labels": ue_types_names,
},
),
(
"ects",
{
"size": 4,
"type": "float",
"min_value": 0,
"max_value": 1000,
"title": "ECTS",
"explanation": "nombre de crédits ECTS (indiquer 0 si UE bonus)"
+ (
". (si les ECTS dépendent du parcours, voir plus bas.)"
if is_apc
else ""
),
"allow_null": not is_apc, # ects requis en APC
},
),
]
if is_apc: # coef pour la moyenne RCUE
form_descr.append(
(
"coef_rcue",
{
"size": 4,
"type": "float",
"min_value": 0,
"title": "Coef. RCUE",
"explanation": """pondération utilisée pour le calcul de la moyenne du RCUE. Laisser à 1, sauf si votre établissement a explicitement décidé de pondérations.
""",
"defaut": 1.0,
"allow_null": False,
"enabled": is_apc,
},
)
)
else: # non APC, coef d'UE
form_descr.append(
(
"coefficient",
{
"size": 4,
"type": "float",
"min_value": 0,
"title": "Coefficient",
"explanation": """les coefficients d'UE ne sont utilisés que
lorsque l'option <em>Utiliser les coefficients d'UE pour calculer
la moyenne générale</em> est activée. Par défaut, le coefficient
d'une UE est simplement la somme des coefficients des modules dans
lesquels l'étudiant a des notes.
Jamais utilisé en BUT.
""",
"enabled": not is_apc,
},
)
)
form_descr += [
(
"ue_code",
{
"size": 12,
"title": "Code UE",
"max_length": SHORT_STR_LEN,
"explanation": """code interne (non vide). Toutes les UE partageant le même code
(et le même code de formation) sont compatibles (compensation de semestres, capitalisation d'UE).
Voir liste ci-dessous.""",
"allow_null": False,
},
),
(
"code_apogee",
{
"title": "Code Apogée",
"size": 25,
"explanation": "(optionnel) code élément pédagogique Apogée ou liste de codes ELP séparés par des virgules",
"max_length": APO_CODE_STR_LEN,
},
),
]
if is_apc:
form_descr += [
(
"code_apogee_rcue",
{
"title": "Code Apogée du RCUE",
"size": 25,
"explanation": "(optionnel) code(s) élément pédagogique Apogée du RCUE",
"max_length": APO_CODE_STR_LEN,
},
),
]
form_descr += [
(
"is_external",
{
"input_type": "boolcheckbox",
"title": "UE externe",
"readonly": not create, # ne permet pas de transformer une UE existante en externe
"explanation": "réservé pour les capitalisations d'UE effectuées à l'extérieur de l'établissement",
},
),
(
"color",
{
"input_type": "color",
"title": "Couleur",
"explanation": "pour affichages",
},
),
]
if create and not cursus.UE_IS_MODULE and not is_apc:
form_descr.append(
(
"create_matiere",
{
"input_type": "boolcheckbox",
"default": True,
"title": "Créer matière identique",
"explanation": "créer immédiatement une matière dans cette UE (utile si on n'utilise pas de matières)",
},
)
)
tf = TrivialFormulator(
request.base_url,
scu.get_request_args(),
form_descr,
initvalues=initvalues,
submitlabel=submitlabel,
cancelbutton="Revenir à la formation",
)
if tf[0] == 0:
ue_parcours_div = ""
if ue and is_apc:
ue_parcours_div = apc_edit_ue.form_ue_choix_parcours(ue)
if ue and ue.modules.count() and ue.semestre_idx is not None:
modules_div = f"""<div class="scobox" id="ue_list_modules">
<div><b>{ue.modules.count()} modules sont rattachés
à cette UE</b> du semestre S{ue.semestre_idx},
elle ne peut donc pas être changée de semestre.</div>
<ul>"""
for m in ue.modules:
modules_div += f"""<li><a class="stdlink" href="{url_for(
"notes.module_edit",
scodoc_dept=g.scodoc_dept, module_id=m.id)
}">{m.code} {m.titre or "sans titre"}</a></li>"""
modules_div += """</ul></div>"""
else:
modules_div = ""
if ue:
clone_form = f"""
<form action="ue_clone" class="clone_form" method="post">
<input type="hidden" name="ue_id" value="{ue.id}">
<button type="submit">Créer une copie de cette UE</button>
</form>
"""
else:
clone_form = ""
return f"""
{html_sco_header.sco_header(page_title=title, javascripts=["js/edit_ue.js"])}
<h2>{title}, (formation {formation.acronyme}, version {formation.version})</h2>
<p class="help">Les UEs sont des groupes de modules dans une formation donnée,
utilisés pour la validation (on calcule des moyennes par UE et applique des
seuils ("barres")).
</p>
<p class="help">Note: sauf exception, l'UE n'a pas de coefficient associé.
Seuls les <em>modules</em> ont des coefficients.
</p>
<div class="scobox">
<div class="scobox-title">
Édition de l'UE {('du semestre S'+str(ue.semestre_idx)) if is_apc and ue else ''}
</div>
{tf[1]}
</div>
{clone_form}
{ue_parcours_div}
{modules_div}
<div id="bonus_description" class="scobox"></div>
<div id="ue_list_code" class="sco_box sco_green_bg"></div>
{html_sco_header.sco_footer()}
"""
elif tf[0] == 1:
if create:
if not tf[2]["ue_code"]:
del tf[2]["ue_code"]
if not tf[2]["numero"]:
# numero regroupant par semestre ou année:
tf[2]["numero"] = next_ue_numero(
formation_id, int(tf[2]["semestre_idx"])
)
ue_id = do_ue_create(tf[2])
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
# (inutilisée mais à laquelle les modules sont rattachés)
matiere_id = sco_edit_matiere.do_matiere_create(
{"ue_id": ue_id, "titre": tf[2]["titre"], "numero": 1},
)
if cursus.UE_IS_MODULE:
# dans ce mode, crée un (unique) module dans l'UE:
_ = sco_edit_module.do_module_create(
{
"titre": tf[2]["titre"],
"code": tf[2]["acronyme"],
# tous les modules auront coef 1, et on utilisera les ECTS:
"coefficient": 1.0,
"ue_id": ue_id,
"matiere_id": matiere_id,
"formation_id": formation_id,
"semestre_id": tf[2]["semestre_idx"],
},
)
ue = db.session.get(UniteEns, ue_id)
flash(f"UE créée (code {ue.ue_code})")
else:
if not tf[2]["numero"]:
tf[2]["numero"] = 0
do_ue_edit(tf[2])
flash("UE modifiée")
if tf[2]:
dest_semestre_idx = tf[2]["semestre_idx"]
elif ue:
dest_semestre_idx = ue.semestre_idx
elif default_semestre_idx:
dest_semestre_idx = default_semestre_idx
elif "semestre_idx" in request.form:
dest_semestre_idx = request.form["semestre_idx"]
else:
dest_semestre_idx = 1
return flask.redirect(
url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
formation_id=formation_id,
semestre_idx=dest_semestre_idx,
)
)
def _add_ue_semestre_id(ues: list[dict], is_apc):
"""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
modules = sco_edit_module.module_list(args={"ue_id": ue["ue_id"]})
if modules:
ue["semestre_id"] = modules[0]["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.
Si le semestre est specifie, cherche les UE ayant des modules de ce semestre
"""
formation = db.session.get(Formation, formation_id)
ues = ue_list(args={"formation_id": formation_id})
if not ues:
return 0
if semestre_id is None:
return ues[-1]["numero"] + 1000
else:
# 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["semestre_id"] == semestre_id]
if ue_list_semestre:
return ue_list_semestre[-1]["numero"] + 10
else:
return ues[-1]["numero"] + 1000
def ue_delete(ue_id=None, delete_validations=False, dialog_confirmed=False):
"""Delete an UE"""
ue = UniteEns.query.get_or_404(ue_id)
if ue.modules.all():
raise ScoValueError(
f"""Suppression de l'UE {ue.titre} impossible car
des modules (ou SAÉ ou ressources) lui sont rattachés.""",
dest_url=url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
formation_id=ue.formation.id,
semestre_idx=ue.semestre_idx,
),
)
if not ue.can_be_deleted():
raise ScoNonEmptyFormationObject(
"UE",
msg=f"{ue.titre or ''} ({ue.acronyme})",
dest_url=url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
formation_id=ue.formation_id,
semestre_idx=ue.semestre_idx,
),
)
if not dialog_confirmed:
return scu.confirm_dialog(
f"<h2>Suppression de l'UE {ue.titre or ''} ({ue.acronyme})</h2>",
dest_url="",
parameters={"ue_id": ue.id},
cancel_url=url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
formation_id=ue.formation_id,
semestre_idx=ue.semestre_idx,
),
)
return do_ue_delete(ue, delete_validations=delete_validations)
def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list
"""Liste des matières et modules d'une formation, avec liens pour
éditer (si non verrouillée).
"""
from app.scodoc import sco_formsemestre_validation
formation: Formation = db.session.get(Formation, formation_id)
if not formation:
raise ScoValueError("invalid formation_id")
parcours = formation.get_cursus()
is_apc = parcours.APC_SAE
if semestre_idx == "all" or semestre_idx == "":
semestre_idx = None
else:
semestre_idx = int(semestre_idx)
show_tags = scu.to_bool(request.args.get("show_tags", 0))
locked = formation.has_locked_sems(semestre_idx)
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_obj = UniteEns.query.filter_by(
formation_id=formation_id, is_external=False
).order_by(UniteEns.semestre_idx, UniteEns.numero)
# 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 !
numeros = {ue.numero for ue in ues_obj}
if (None in numeros) or len(numeros) < ues_obj.count():
scu.objects_renumber(db, ues_obj)
ues_externes_obj = UniteEns.query.filter_by(
formation_id=formation_id, is_external=True
)
# liste ordonnée des formsemestres de cette formation:
formsemestres = sorted(
FormSemestre.query.filter_by(formation_id=formation_id).all(),
key=lambda s: s.sort_key(),
reverse=True,
)
if is_apc:
# Pour faciliter la transition des anciens programmes non APC
for ue in ues_obj:
ue.guess_semestre_idx()
# vérifie qu'on a bien au moins une matière dans chaque UE
if ue.matieres.count() < 1:
mat = Matiere(ue_id=ue.id)
db.session.add(mat)
# donne des couleurs aux UEs crées avant
colorie_anciennes_ues(ues_obj)
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:
_add_ue_semestre_id(ues, is_apc)
_add_ue_semestre_id(ues_externes, is_apc)
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)
seen = set()
duplicated_codes = {
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]
has_perm_change = current_user.has_permission(Permission.EditFormation)
# editable = (not locked) and has_perm_change
# On autorise maintenant la modification des formations qui ont
# des semestres verrouillés, sauf si cela affect les notes passées
# (verrouillées):
# - pas de modif des modules utilisés dans des semestres verrouillés
# - pas de changement des codes d'UE utilisés dans des semestres verrouillés
editable = has_perm_change
tag_editable = (
current_user.has_permission(Permission.EditFormationTags) or has_perm_change
)
if locked:
lockicon = scu.icontag("lock32_img", title="verrouillé")
else:
lockicon = ""
arrow_up, arrow_down, arrow_none = sco_groups.get_arrow_icons_tags()
delete_icon = scu.icontag(
"delete_small_img", title="Supprimer (module inutilisé)", alt="supprimer"
)
delete_disabled_icon = scu.icontag(
"delete_small_dis_img", title="Suppression impossible (module utilisé)"
)
H = [
f"""<h2>{formation.html()} {lockicon}
</h2>
""",
]
if locked:
H.append(
"""<p class="help">Cette formation est verrouillée car
des semestres verrouillés s'y réferent.
Si vous souhaitez modifier cette formation (par exemple pour y ajouter un module),
vous devez:
</p>
<ul class="help">
<li>soit créer une nouvelle version de cette formation pour pouvoir l'éditer
librement (vous pouvez passer par la fonction "Associer à une nouvelle version
du programme" (menu "Semestre") si vous avez un semestre en cours);
</li>
<li>soit déverrouiller le ou les semestres qui s'y réfèrent (attention, en
principe ces semestres sont archivés et ne devraient pas être modifiés).
</li>
</ul>"""
)
if msg:
H.append('<p class="msg">' + msg + "</p>")
if ues_with_duplicated_code:
H.append(
f"""<div class="ue_warning"><span>Attention: plusieurs UEs de cette
formation ont le même code : <tt>{
', '.join([
'<a class="stdlink" href="' + url_for( "notes.ue_edit",
scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"] )
+ '">' + ue["acronyme"] + " (code " + ue["ue_code"] + ")</a>"
for ue in ues_with_duplicated_code ])
}</tt>.
Il faut corriger cela, sinon les capitalisations et ECTS seront
erronés !</span></div>"""
)
# Description de la formation
H.append(
render_template(
"pn/form_descr.j2",
formation=formation,
parcours=parcours,
editable=editable,
)
)
# Formation APC (BUT) ?
if is_apc:
lock_info = (
"""<span class="lock_info">verrouillé (voir liste des semestres utilisateurs
en bas de page)</span>
"""
if locked
else ""
)
H.append(
f"""<div class="formation_apc_infos">
<div class="ue_list_tit">Formation par compétences (BUT)
- {_html_select_semestre_idx(formation_id, semestre_ids, semestre_idx)}
</form>
{lock_info}
</div>
"""
)
if formation.referentiel_competence is None:
descr_refcomp = ""
msg_refcomp = "associer à un référentiel de compétences"
else:
descr_refcomp = f"""Référentiel de compétences:
<a href="{url_for('notes.refcomp_show',
scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id)}"
class="stdlink">
{formation.referentiel_competence.get_title()}
</a>&nbsp;"""
msg_refcomp = "changer"
H.append(f"""<ul><li>{descr_refcomp}""")
if current_user.has_permission(Permission.EditFormation):
if (
formation.referentiel_competence is None
or formation.formsemestres.count() == 0
):
H.append(
f"""<a class="stdlink" href="{url_for('notes.refcomp_assoc_formation',
scodoc_dept=g.scodoc_dept, formation_id=formation_id)
}">{msg_refcomp}</a>"""
)
elif formation.referentiel_competence is not None:
H.append("""(non modifiable car utilisé par des semestres)""")
H.append("</li>")
if formation.referentiel_competence is not None:
H.append(
"""<li>Parcours, compétences et UEs&nbsp;:
<div class="formation_parcs">
"""
)
for parc in formation.referentiel_competence.parcours:
H.append(
f"""<div><a href="{url_for("notes.parcour_formation",
scodoc_dept=g.scodoc_dept, formation_id=formation.id, parcour_id=parc.id )
}">{parc.code}</a></div>"""
)
H.append("""</div></li>""")
H.append(
f"""
<li> <a class="stdlink" href="{
url_for('notes.edit_modules_ue_coefs',
scodoc_dept=g.scodoc_dept, formation_id=formation_id, semestre_idx=semestre_idx)
}">{'Visualiser' if locked else 'Éditer'} les coefficients des ressources et SAÉs</a>
</li>
</ul>
"""
)
# Description des UE/matières/modules
H.append(
f"""
<div class="formation_ue_list">
<div class="ue_list_tit">Formation (programme pédagogique):</div>
<form>
<input type="checkbox" class="sco_tag_checkbox"
{'checked' if show_tags else ''}
> Montrer les tags des modules voire en ajouter
<i>(ceux correspondant aux titres des compétences étant ajoutés par défaut)</i>
</input>
</form>
"""
)
if is_apc:
H.append(
sco_edit_apc.html_edit_formation_apc(
formation,
semestre_idx=semestre_idx,
editable=editable,
tag_editable=tag_editable,
)
)
else:
H.append('<div class="formation_classic_infos">')
H.append(
_ue_table_ues(
parcours,
ues,
editable,
tag_editable,
has_perm_change,
arrow_up,
arrow_down,
arrow_none,
delete_icon,
delete_disabled_icon,
)
)
if editable:
H.append(
f"""<ul>
<li><a class="stdlink" href="{
url_for('notes.ue_create', scodoc_dept=g.scodoc_dept, formation_id=formation_id)
}">Ajouter une UE</a>
</li>
<li><a href="{
url_for('notes.formation_add_malus_modules',
scodoc_dept=g.scodoc_dept, formation_id=formation_id)
}" class="stdlink">Ajouter des modules de malus dans chaque UE</a>
</li>
</ul>
"""
)
H.append("</div>")
H.append("</div>") # formation_ue_list
if ues_externes:
H.append(
f"""
<div class="formation_ue_list formation_ue_list_externes">
<div class="ue_list_tit">UE externes déclarées (pour information):
</div>
{_ue_table_ues(
parcours,
ues_externes,
editable,
tag_editable,
has_perm_change,
arrow_up,
arrow_down,
arrow_none,
delete_icon,
delete_disabled_icon,
)}
</div>
"""
)
H.append("<p><ul>")
if has_perm_change:
H.append(
f"""
<li><a class="stdlink" href="{
url_for('notes.formsemestre_associate_new_version',
scodoc_dept=g.scodoc_dept, formation_id=formation_id
)
}">Créer une nouvelle version de la formation</a> (copie non verrouillée)
</li>
"""
)
if not len(formsemestres):
H.append(
f"""
<li><a class="stdlink" href="{
url_for('notes.formation_delete',
scodoc_dept=g.scodoc_dept, formation_id=formation_id
)
}">Supprimer cette formation</a> (pas encore utilisée par des semestres)
</li>
"""
)
H.append(
f"""
<li><a class="stdlink" href="{
url_for('notes.formation_table_recap', scodoc_dept=g.scodoc_dept,
formation_id=formation_id)
}">Table récapitulative de la formation</a>
</li>
<li><a class="stdlink" href="{
url_for('notes.formation_tag_modules_by_type', scodoc_dept=g.scodoc_dept,
formation_id=formation_id,
semestre_idx=1 if semestre_idx is None else semestre_idx
)
}">Tagguer tous les modules par leur type</a> (tag <tt>res</tt>, <tt>sae</tt>).
</li>
<li><a class="stdlink" href="{
url_for('notes.formation_export', scodoc_dept=g.scodoc_dept,
formation_id=formation_id, fmt='xml')
}">Export XML de la formation</a> ou
<a class="stdlink" href="{
url_for('notes.formation_export', scodoc_dept=g.scodoc_dept,
formation_id=formation_id, fmt='xml', export_codes_apo=0)
}">sans codes Apogée</a>
(permet de l'enregistrer pour l'échanger avec un autre site)
</li>
<li><a class="stdlink" href="{
url_for('notes.formation_export', scodoc_dept=g.scodoc_dept,
formation_id=formation_id, fmt='json')
}">Export JSON de la formation</a>
</li>
<li><a class="stdlink" href="{
url_for('notes.module_table', scodoc_dept=g.scodoc_dept,
formation_id=formation_id)
}">Liste détaillée des modules de la formation</a> (debug)
</li>
</ul>
</p>"""
)
if has_perm_change:
H.append(
"""
<h3> <a name="sems">Semestres ou sessions de cette formation</a></h3>
<p><ul>"""
)
for formsemestre in formsemestres:
H.append(f"""<li>{formsemestre.html_link_status()}""")
if not formsemestre.etat:
H.append(" [verrouillé]")
else:
H.append(
f""" &nbsp;<a class="stdlink" href="{url_for("notes.formsemestre_editwithmodules",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id
)}">Modifier</a>"""
)
H.append("</li>")
H.append("</ul>")
if current_user.has_permission(Permission.EditFormSemestre):
H.append(
f"""<ul>
<li><b><a class="stdlink" href="{
url_for('notes.formsemestre_createwithmodules', scodoc_dept=g.scodoc_dept,
formation_id=formation_id, semestre_id=1)
}">Mettre en place un nouveau semestre de formation {formation.acronyme}</a></b>
</li>
</ul>"""
)
# <li>(debug) <a class="stdlink" href="check_form_integrity?formation_id=%(formation_id)s">Vérifier cohérence</a></li>
warn, _ = sco_formsemestre_validation.check_formation_ues(formation)
H.append(warn)
return render_template(
"sco_page_dept.j2",
content="".join(H),
page_title=f"Formation {formation.acronyme} v{formation.version}",
cssstyles=html_sco_header.BOOTSTRAP_CSS
+ ["libjs/jQuery-tagEditor/jquery.tag-editor.css", "css/ue_table.css"],
javascripts=html_sco_header.BOOTSTRAP_JS
+ [
"libjs/jinplace-1.2.1.min.js",
"js/ue_list.js",
"js/edit_ue.js",
"libjs/jQuery-tagEditor/jquery.tag-editor.min.js",
"libjs/jQuery-tagEditor/jquery.caret.min.js",
"js/module_tag_editor.js",
],
)
def _html_select_semestre_idx(formation_id, semestre_ids, semestre_idx):
htm = """<form method="get">Semestre:
<select onchange="this.form.submit()" name="semestre_idx" id="semestre_idx" >
"""
for i in list(semestre_ids) + ["all"]:
if i == "all":
label = "tous"
else:
label = f"S{i}"
htm += f"""<option value="{i}" {
'selected'
if (semestre_idx == i)
or (i == "all" and semestre_idx is None)
else ''
}>{label}</option>
"""
htm += f"""
</select>
<input type="hidden" name="formation_id" value="{formation_id}"></input>
<input type="hidden" name="show_tags" value="0"></input>
</form>"""
return htm
def _ue_table_ues(
parcours,
ues: list[dict],
editable,
tag_editable,
has_perm_change,
arrow_up,
arrow_down,
arrow_none,
delete_icon,
delete_disabled_icon,
) -> str:
"""Édition de programme: liste des UEs (avec leurs matières et modules).
Pour les formations classiques (non APC/BUT)
"""
H = []
cur_ue_semestre_id = None
iue = 0
for ue in ues:
if ue["ects"] is None:
ue["ects_str"] = ""
else:
ue["ects_str"] = ", %g ECTS" % ue["ects"]
if editable:
klass = "span_apo_edit"
else:
klass = ""
edit_url = url_for(
"apiweb.ue_set_code_apogee",
scodoc_dept=g.scodoc_dept,
ue_id=ue["ue_id"],
)
ue[
"code_apogee_str"
] = f""", Apo: <span
class="{klass}" data-url="{edit_url}" id="{ue['ue_id']}"
data-placeholder="{scu.APO_MISSING_CODE_STR}">{
ue["code_apogee"] or ""
}</span>"""
if cur_ue_semestre_id != ue["semestre_id"]:
cur_ue_semestre_id = ue["semestre_id"]
if ue["semestre_id"] == codes_cursus.UE_SEM_DEFAULT:
lab = "Pas d'indication de semestre:"
else:
lab = f"""Semestre {ue["semestre_id"]}:"""
H.append(
f'<div class="ue_list_div"><div class="ue_list_tit_sem">{lab}</div>'
)
H.append('<ul class="notes_ue_list">')
H.append('<li class="notes_ue_list">')
if iue != 0 and editable:
H.append(
'<a href="ue_move?ue_id=%s&after=0" class="aud">%s</a>'
% (ue["ue_id"], arrow_up)
)
else:
H.append(arrow_none)
if iue < len(ues) - 1 and editable:
H.append(
'<a href="ue_move?ue_id=%s&after=1" class="aud">%s</a>'
% (ue["ue_id"], arrow_down)
)
else:
H.append(arrow_none)
ue["acro_titre"] = str(ue["acronyme"])
if ue["titre"] != ue["acronyme"]:
ue["acro_titre"] += " " + str(ue["titre"])
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>
<span class="ue_coef"></span>
"""
% ue
)
if ue["type"] != codes_cursus.UE_STANDARD:
H.append(
'<span class="ue_type">%s</span>'
% codes_cursus.UE_TYPE_NAME[ue["type"]]
)
if ue["is_external"]:
# 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)
# 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:
H.append('<span class="ue_is_external">')
if has_perm_change:
H.append(
f"""<a class="stdlink" href="{
url_for("notes.ue_set_internal",
scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"])
}">transformer en UE ordinaire</a>&nbsp;"""
)
H.append("</span>")
ue_editable = editable and not ue_is_locked(ue["ue_id"])
if ue_editable:
H.append(
f"""<a class="stdlink" href="{
url_for("notes.ue_edit", scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"])
}">modifier</a>"""
)
else:
H.append('<span class="locked">[verrouillé]</span>')
H.append(
_ue_table_matieres(
parcours,
ue,
editable,
tag_editable,
arrow_up,
arrow_down,
arrow_none,
delete_icon,
delete_disabled_icon,
)
)
if (iue >= len(ues) - 1) or ue["semestre_id"] != ues[iue + 1]["semestre_id"]:
H.append(
f"""</ul><ul><li><a href="{url_for('notes.ue_create', scodoc_dept=g.scodoc_dept,
formation_id=ue['formation_id'], semestre_idx=ue['semestre_id'])
}">Ajouter une UE dans le semestre {ue['semestre_id'] or ''}</a></li></ul>
</div>
"""
)
iue += 1
return "\n".join(H)
def _ue_table_matieres(
parcours,
ue,
editable,
tag_editable,
arrow_up,
arrow_down,
arrow_none,
delete_icon,
delete_disabled_icon,
):
"""Édition de programme: liste des matières (et leurs modules) d'une UE."""
H = []
if not parcours.UE_IS_MODULE:
H.append('<ul class="notes_matiere_list">')
matieres = sco_edit_matiere.matiere_list(args={"ue_id": ue["ue_id"]})
for mat in matieres:
if not parcours.UE_IS_MODULE:
H.append('<li class="notes_matiere_list">')
if editable and not sco_edit_matiere.matiere_is_locked(mat["matiere_id"]):
H.append(
f"""<a class="stdlink" href="{
url_for("notes.matiere_edit",
scodoc_dept=g.scodoc_dept, matiere_id=mat["matiere_id"])
}">
"""
)
H.append("%(titre)s" % mat)
if editable and not sco_edit_matiere.matiere_is_locked(mat["matiere_id"]):
H.append("</a>")
modules = sco_edit_module.module_list(args={"matiere_id": mat["matiere_id"]})
H.append(
_ue_table_modules(
parcours,
ue,
mat,
modules,
editable,
tag_editable,
arrow_up,
arrow_down,
arrow_none,
delete_icon,
delete_disabled_icon,
)
)
if not parcours.UE_IS_MODULE:
H.append("</li>")
if not matieres:
H.append("<li>Aucune matière dans cette UE ! ")
if editable:
H.append(
"""<a class="stdlink" href="ue_delete?ue_id=%(ue_id)s">supprimer l'UE</a>"""
% ue
)
H.append("</li>")
if editable and not parcours.UE_IS_MODULE:
H.append(
'<li><a class="stdlink" href="matiere_create?ue_id=%(ue_id)s">créer une matière</a> </li>'
% ue
)
if not parcours.UE_IS_MODULE:
H.append("</ul>")
return "\n".join(H)
def _ue_table_modules(
parcours,
ue,
mat,
modules,
editable,
tag_editable,
arrow_up,
arrow_down,
arrow_none,
delete_icon,
delete_disabled_icon,
unit_name="matière",
add_suppress_link=True, # lien "supprimer cette matière"
empty_list_msg="Aucun élément dans cette matière",
create_element_msg="créer un module",
):
"""Édition de programme: liste des modules d'une matière d'une UE"""
H = ['<ul class="notes_module_list">']
im = 0
for mod in modules:
mod["nb_moduleimpls"] = sco_edit_module.module_count_moduleimpls(
mod["module_id"]
)
klass = "notes_module_list"
if mod["module_type"] == ModuleType.MALUS:
klass += " module_malus"
H.append('<li class="%s">' % klass)
H.append('<span class="notes_module_list_buts">')
if im != 0 and editable:
H.append(
'<a href="module_move?module_id=%s&after=0" class="aud">%s</a>'
% (mod["module_id"], arrow_up)
)
else:
H.append(arrow_none)
if im < len(modules) - 1 and editable:
H.append(
'<a href="module_move?module_id=%s&after=1" class="aud">%s</a>'
% (mod["module_id"], arrow_down)
)
else:
H.append(arrow_none)
im += 1
if mod["nb_moduleimpls"] == 0 and editable:
icon = delete_icon
else:
icon = delete_disabled_icon
H.append(
'<a class="smallbutton" href="module_delete?module_id=%s">%s</a>'
% (mod["module_id"], icon)
)
H.append("</span>")
mod_editable = (
editable # and not sco_edit_module.module_is_locked( Mod['module_id'])
)
if mod_editable:
H.append(
'<a class="discretelink" title="Modifier le module numéro %(numero)s, utilisé par %(nb_moduleimpls)d sessions" href="module_edit?module_id=%(module_id)s">'
% mod
)
if mod["module_type"] not in (scu.ModuleType.STANDARD, scu.ModuleType.MALUS):
H.append(
f"""<span class="invalid-module-type">{scu.EMO_WARNING} type incompatible </span>"""
)
H.append(
'<span class="formation_module_tit">%s</span>'
% scu.join_words(mod["code"], mod["titre"])
)
if mod_editable:
H.append("</a>")
heurescoef = (
"%(heures_cours)s/%(heures_td)s/%(heures_tp)s, coef. %(coefficient)s" % mod
)
edit_url = url_for(
"apiweb.formation_module_set_code_apogee",
scodoc_dept=g.scodoc_dept,
module_id=mod["module_id"],
)
heurescoef += f""", Apo: <span
class="{'span_apo_edit' if editable else ''}"
data-url="{edit_url}" id="{mod["module_id"]}"
data-placeholder="{scu.APO_MISSING_CODE_STR}">{
mod["code_apogee"] or ""
}</span>"""
if tag_editable:
tag_cls = "module_tag_editor"
else:
tag_cls = "module_tag_editor_ro"
tag_mk = """<span class="sco_tag_edit"><form><textarea data-module_id="{}" class="{}">{}</textarea></form></span>"""
tag_edit = tag_mk.format(
mod["module_id"],
tag_cls,
",".join(sco_tag_module.module_tag_list(mod["module_id"])),
)
if ue["semestre_idx"] is not None and mod["semestre_id"] != ue["semestre_idx"]:
warning_semestre = ' <span class="red">incohérent ?</span>'
else:
warning_semestre = ""
H.append(
" %s %s%s" % (parcours.SESSION_NAME, mod["semestre_id"], warning_semestre)
+ " (%s)" % heurescoef
+ tag_edit
)
H.append("</li>")
if not modules:
H.append(f"<li>{empty_list_msg} ! ")
if editable and add_suppress_link:
H.append(
f"""<a class="stdlink" href="{
url_for("notes.matiere_delete",
scodoc_dept=g.scodoc_dept, matiere_id=mat["matiere_id"])}"
>la supprimer</a>
"""
)
H.append("</li>")
if editable: # and ((not parcours.UE_IS_MODULE) or len(Modlist) == 0):
H.append(
f"""<li> <a class="stdlink" href="{
url_for("notes.module_create",
scodoc_dept=g.scodoc_dept, matiere_id=mat["matiere_id"])}"
>{create_element_msg}</a></li>
"""
)
H.append("</ul>")
return "\n".join(H)
def ue_sharing_code(ue_code: str = "", ue_id: int = None, hide_ue_id: int = None):
"""HTML list of UE sharing this code
Either ue_code or ue_id may be specified.
hide_ue_id spécifie un id à retirer de la liste.
"""
if ue_id is not None:
ue = UniteEns.query.get_or_404(ue_id)
if not ue_code:
ue_code = ue.ue_code
formation_code = ue.formation.formation_code
# UE du même code, code formation et departement:
q_ues = (
UniteEns.query.filter_by(ue_code=ue_code)
.join(UniteEns.formation)
.filter_by(dept_id=g.scodoc_dept_id, formation_code=formation_code)
)
else:
# Toutes les UE du departement avec ce code:
q_ues = (
UniteEns.query.filter_by(ue_code=ue_code)
.join(UniteEns.formation)
.filter_by(dept_id=g.scodoc_dept_id)
)
if hide_ue_id is not None: # enlève l'ue de depart
q_ues = q_ues.filter(UniteEns.id != hide_ue_id)
ues = q_ues.all()
msg = " dans les formations du département "
if not ues:
if ue_id is not None:
return f"""<span class="ue_share">Seule UE avec code {
ue_code if ue_code is not None else '-'}{msg}</span>"""
else:
return f"""<span class="ue_share">Aucune UE avec code {
ue_code if ue_code is not None else '-'}{msg}</span>"""
H = []
if ue_id:
H.append(
f"""<span class="ue_share">Pour information, autres UEs avec le code {
ue_code if ue_code is not None else '-'}{msg}:</span>"""
)
else:
H.append(
f"""<span class="ue_share">UE avec le code {
ue_code if ue_code is not None else '-'}{msg}:</span>"""
)
H.append("<ul>")
for ue in ues:
H.append(
f"""<li>{ue.acronyme} ({ue.titre or ''}) dans
<a class="stdlink" href="{
url_for("notes.ue_table",
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)}"
>{ue.formation.acronyme} ({ue.formation.titre})</a>, version {ue.formation.version}
</li>
"""
)
H.append("</ul>")
return "\n".join(H)
def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False):
"edit an UE"
# check
ue_id = args["ue_id"]
ue = ue_list({"ue_id": ue_id})[0]
if (not bypass_lock) and ue_is_locked(ue["ue_id"]):
raise ScoLockedFormError()
# check: acronyme unique dans cette formation
if "acronyme" in args:
new_acro = args["acronyme"]
ues = ue_list({"formation_id": ue["formation_id"], "acronyme": new_acro})
if ues and ues[0]["ue_id"] != ue_id:
raise ScoValueError(
f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé !
(chaque UE doit avoir un acronyme unique dans la formation.)"""
)
# On ne peut pas supprimer le code UE:
if "ue_code" in args and not args["ue_code"]:
del args["ue_code"]
cnx = ndb.GetDBConnexion()
_ueEditor.edit(cnx, args)
formation = db.session.get(Formation, ue["formation_id"])
if not dont_invalidate_cache:
# Invalide les semestres utilisant cette formation
# ainsi que les poids et coefs
formation.invalidate_module_coefs()
def ue_is_locked(ue_id):
"""True if UE should not be modified
(contains modules used in a locked formsemestre)
"""
r = ndb.SimpleDictFetch(
"""SELECT ue.id
FROM notes_ue ue, notes_modules mod, notes_formsemestre sem, notes_moduleimpl mi
WHERE ue.id = mod.ue_id
AND mi.module_id = mod.id AND mi.formsemestre_id = sem.id
AND ue.id = %(ue_id)s AND sem.etat = false
""",
{"ue_id": ue_id},
)
return len(r) > 0
UE_PALETTE = [
"#B80004", # rouge
"#F97B3D", # Orange Crayola
"#FEB40B", # Honey Yellow
"#80CB3F", # Yellow Green
"#05162E", # Oxford Blue
"#548687", # Steel Teal
"#444054", # Independence
"#889696", # Spanish Gray
"#0CA4A5", # Viridian Green
]
def colorie_anciennes_ues(ues: list[UniteEns]) -> None:
"""Avant ScoDoc 9.2, les ue n'avaient pas de couleurs
Met des défauts raisonnables
"""
nb_colors = len(UE_PALETTE)
index = 0
last_sem_idx = 0
for ue in ues:
if ue.semestre_idx != last_sem_idx:
index = 0
last_sem_idx = ue.semestre_idx
if ue.color is None:
ue.color = UE_PALETTE[index % nb_colors]
index += 1
db.session.add(ue)
def ue_guess_color_default(formation_id: int, default_semestre_idx: int) -> str:
"""Un code couleur pour une nouvelle UE dans ce semestre"""
nb_colors = len(UE_PALETTE)
# UE existantes dans ce semestre:
nb_ues = UniteEns.query.filter(
UniteEns.formation_id == formation_id,
UniteEns.semestre_idx == default_semestre_idx,
).count()
index = nb_ues
return UE_PALETTE[index % nb_colors]