WIP: BUT association modules <-> parcours

This commit is contained in:
Emmanuel Viennet 2022-05-01 23:58:41 +02:00
parent 5d7085b858
commit 72dc72d286
9 changed files with 441 additions and 135 deletions

View File

@ -1,14 +1,25 @@
# -*- coding: UTF-8 -*
"""Modèles base de données ScoDoc
XXX version préliminaire ScoDoc8 #sco8 sans département
"""
import sqlalchemy
CODE_STR_LEN = 16 # chaine pour les codes
SHORT_STR_LEN = 32 # courtes chaine, eg acronymes
APO_CODE_STR_LEN = 512 # nb de car max d'un code Apogée (il peut y en avoir plusieurs)
GROUPNAME_STR_LEN = 64
convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
metadata_obj = sqlalchemy.MetaData(naming_convention=convention)
from app.models.raw_sql_init import create_database_functions
from app.models.absences import Absence, AbsenceNotification, BilletAbsence

View File

@ -321,6 +321,21 @@ ApcAppCritiqueModules = db.Table(
)
parcours_modules = db.Table(
"parcours_modules",
db.Column(
"parcours_id", db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True
),
db.Column(
"module_id",
db.Integer,
db.ForeignKey("notes_modules.id", ondelete="CASCADE"),
primary_key=True,
),
)
"""Association parcours <-> modules (many-to-many)"""
class ApcParcours(db.Model, XMLModel):
id = db.Column(db.Integer, primary_key=True)
referentiel_id = db.Column(
@ -335,6 +350,11 @@ class ApcParcours(db.Model, XMLModel):
lazy="dynamic",
cascade="all, delete-orphan",
)
# modules = db.relationship(
# "Module",
# secondary=parcours_modules,
# back_populates="parcours",
# )
def __repr__(self):
return f"<{self.__class__.__name__} {self.code}>"

View File

@ -3,6 +3,7 @@
from app import db
from app.models import APO_CODE_STR_LEN
from app.models.but_refcomp import parcours_modules
from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.sco_utils import ModuleType
@ -44,6 +45,14 @@ class Module(db.Model):
lazy=True,
backref=db.backref("modules", lazy=True),
)
# BUT
parcours = db.relationship(
"ApcParcours",
secondary=parcours_modules,
lazy="subquery",
# cascade="all, delete",
backref=db.backref("modules", lazy=True),
)
def __init__(self, **kwargs):
self.ue_coefs = []

View File

@ -134,7 +134,10 @@ def html_edit_formation_apc(
tag_editable=tag_editable,
icons=icons,
scu=scu,
),
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else "",
render_template(
"pn/form_mods.html",
formation=formation,
@ -147,7 +150,10 @@ def html_edit_formation_apc(
tag_editable=tag_editable,
icons=icons,
scu=scu,
),
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else "",
render_template(
"pn/form_mods.html",
formation=formation,
@ -159,7 +165,10 @@ def html_edit_formation_apc(
tag_editable=tag_editable,
icons=icons,
scu=scu,
),
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else """<span class="fontred">créer une UE pour pouvoir ajouter des modules</span>""",
]
return "\n".join(H)

View File

@ -33,12 +33,13 @@ from flask import url_for, render_template
from flask import g, request
from flask_login import current_user
from app import log
from app import db, log
from app import models
from app.models import APO_CODE_STR_LEN
from app.models import Formation, Matiere, Module, UniteEns
from app.models import FormSemestre, ModuleImpl
from app.models import ScolarNews
from app.models.but_refcomp import ApcParcours
import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
@ -121,6 +122,13 @@ def module_create(
Sinon, donne le choix de l'UE de rattachement et utilise la première
matière de cette UE (si elle n'existe pas, la crée).
"""
return module_edit(
create=True,
matiere_id=matiere_id,
module_type=module_type,
semestre_id=semestre_id,
formation_id=formation_id,
)
if matiere_id:
matiere = Matiere.query.get_or_404(matiere_id)
ue = matiere.ue
@ -472,30 +480,56 @@ def check_module_code_unicity(code, field, formation_id, module_id=None):
return len(Mods) == 0
def module_edit(module_id=None):
"""Edit a module"""
from app.scodoc import sco_formations
def module_edit(
module_id=None,
create=False,
matiere_id=None,
module_type=None,
semestre_id=None,
formation_id=None,
):
"""Formulaire édition ou création module.
Si create, création nouveau module.
Si matiere_id est spécifié, le module sera créé dans cette matière (cas normal).
Sinon, donne le choix de l'UE de rattachement et utilise la première matière
de cette UE (si elle n'existe pas, la crée).
"""
from app.scodoc import sco_tag_module
# --- Détermination de la formation
orig_semestre_idx = None
if create:
if matiere_id:
matiere = Matiere.query.get_or_404(matiere_id)
ue = matiere.ue
formation = ue.formation
orig_semestre_idx = ue.semestre_idx if semestre_id is None else semestre_id
else:
formation = Formation.query.get_or_404(formation_id)
module = None
unlocked = True
else:
if not module_id:
raise ScoValueError("invalid module !")
modules = module_list(args={"module_id": module_id})
if not modules:
raise ScoValueError("invalid module !")
module = modules[0]
a_module = models.Module.query.get(module_id)
raise ValueError("missing module_id !")
module = models.Module.query.get_or_404(module_id)
module_dict = module.to_dict()
formation = module.formation
unlocked = not module_is_locked(module_id)
formation_id = module["formation_id"]
formation = sco_formations.formation_list(args={"formation_id": formation_id})[0]
parcours = sco_codes_parcours.get_parcours_from_code(formation["type_parcours"])
parcours = sco_codes_parcours.get_parcours_from_code(formation.type_parcours)
is_apc = parcours.APC_SAE # BUT
in_use = len(a_module.modimpls.all()) > 0 # il y a des modimpls
if not create:
orig_semestre_idx = module.ue.semestre_idx if is_apc else module.semestre_id
if orig_semestre_idx is None:
orig_semestre_idx = 1
# il y a-t-il des modimpls ?
in_use = (module is not None) and (len(module.modimpls.all()) > 0)
matieres = Matiere.query.filter(
Matiere.ue_id == UniteEns.id, UniteEns.formation_id == formation_id
Matiere.ue_id == UniteEns.id, UniteEns.formation_id == formation.id
).order_by(UniteEns.semestre_idx, UniteEns.numero, Matiere.numero)
if in_use:
# restreint aux matières du même semestre
matieres = matieres.filter(UniteEns.semestre_idx == a_module.ue.semestre_idx)
matieres = matieres.filter(UniteEns.semestre_idx == module.ue.semestre_idx)
if is_apc:
# ne conserve que la 1ere matière de chaque UE,
@ -503,7 +537,8 @@ def module_edit(module_id=None):
matieres = [
mat
for mat in matieres
if a_module.matiere.id == mat.id or mat.id == mat.ue.matieres.first().id
if ((module is not None) and (module.matiere.id == mat.id))
or (mat.id == mat.ue.matieres.first().id)
]
mat_names = [
"S%s / %s" % (mat.ue.semestre_idx, mat.ue.acronyme) for mat in matieres
@ -511,14 +546,43 @@ def module_edit(module_id=None):
else:
mat_names = ["%s / %s" % (mat.ue.acronyme, mat.titre or "") for mat in matieres]
if module: # edition
ue_mat_ids = ["%s!%s" % (mat.ue.id, mat.id) for mat in matieres]
module["ue_matiere_id"] = "%s!%s" % (module["ue_id"], module["matiere_id"])
module_dict["ue_matiere_id"] = "%s!%s" % (
module_dict["ue_id"],
module_dict["matiere_id"],
)
semestres_indices = list(range(1, parcours.NB_SEM + 1))
# Toutes les UE de la formation (tout parcours):
ues = formation.ues.order_by(
UniteEns.semestre_idx, UniteEns.numero, UniteEns.acronyme
).all()
# --- Titre de la page
if create:
if is_apc and module_type is not None:
object_name = scu.MODULE_TYPE_NAMES[module_type]
else:
object_name = "Module"
page_title = f"Création {object_name}"
if matiere_id:
title = f"""Création {object_name} dans la matière
{matiere.titre},
(UE {ue.acronyme}), semestre {ue.semestre_idx}
"""
else:
title = f"""Création {object_name} dans la formation
{formation.acronyme}"""
else:
page_title = "Modification du module {module.code or module.titre or ''}"
title = f"""Modification du module {module.code or ''} {module.titre or ''}
(formation {formation.acronyme}, version {formation.version})
"""
H = [
html_sco_header.sco_header(
page_title=f"Modification du module {a_module.code or a_module.titre or ''}",
page_title=page_title,
cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css"],
javascripts=[
"libjs/jQuery-tagEditor/jquery.tag-editor.min.js",
@ -526,17 +590,19 @@ def module_edit(module_id=None):
"js/module_tag_editor.js",
],
),
f"""<h2>Modification du module {a_module.code or ''} {a_module.titre or ''}""",
""" (formation %(acronyme)s, version %(version)s)</h2>""" % formation,
f"""<h2>{title}</h2>""",
render_template(
"scodoc/help/modules.html",
is_apc=is_apc,
semestre_id=semestre_id,
formsemestres=FormSemestre.query.filter(
ModuleImpl.formsemestre_id == FormSemestre.id,
ModuleImpl.module_id == module_id,
)
.order_by(FormSemestre.date_debut)
.all(),
.all()
if not create
else None,
),
]
if not unlocked:
@ -547,28 +613,55 @@ def module_edit(module_id=None):
module_types = scu.ModuleType # tous les types
else:
# ne propose pas SAE et Ressources, sauf si déjà de ce type...
module_types = (
set(scu.ModuleType) - {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE}
) | {
scu.ModuleType(a_module.module_type)
if a_module.module_type
module_types = set(scu.ModuleType) - {
scu.ModuleType.RESSOURCE,
scu.ModuleType.SAE,
}
if module:
module_types |= {
scu.ModuleType(module.module_type)
if module.module_type
else scu.ModuleType.STANDARD
}
# Numéro du module
# cherche le numero adéquat (pour placer le module en fin de liste)
if module:
default_num = module.numero
else:
modules = formation.modules.all()
if modules:
default_num = max([m.numero or 0 for m in modules]) + 10
else:
default_num = 10
descr = [
(
"code",
{
"size": 10,
"explanation": "code du module (issu du programme, exemple M1203 ou R2.01. Doit être unique dans la formation)",
"explanation": "code du module (issu du programme, exemple M1203, R2.01 , ou SAÉ 3.4. Doit être unique dans la formation)",
"allow_null": False,
"validator": lambda val, field, formation_id=formation_id: check_module_code_unicity(
val, field, formation_id, module_id=module_id
"validator": lambda val, field, formation_id=formation.id: check_module_code_unicity(
val, field, formation_id, module_id=module.id if module else None
),
},
),
("titre", {"size": 30, "explanation": "nom du module"}),
("abbrev", {"size": 20, "explanation": "nom abrégé (pour bulletins)"}),
(
"titre",
{
"size": 30,
"explanation": """nom du module. Exemple:
<em>Introduction à la démarche ergonomique</em>""",
},
),
(
"abbrev",
{
"size": 20,
"explanation": """nom abrégé (pour bulletins).
Exemple: <em>Intro. à l'ergonomie</em>""",
},
),
(
"module_type",
{
@ -583,33 +676,34 @@ def module_edit(module_id=None):
(
"heures_cours",
{
"title": "Heures CM :",
"title": "Heures cours :",
"size": 4,
"type": "float",
"explanation": "nombre d'heures de cours",
"explanation": "nombre d'heures de cours (optionnel)",
},
),
(
"heures_td",
{
"title": "Heures TD :",
"title": "Heures de TD :",
"size": 4,
"type": "float",
"explanation": "nombre d'heures de Travaux Dirigés",
"explanation": "nombre d'heures de Travaux Dirigés (optionnel)",
},
),
(
"heures_tp",
{
"title": "Heures TP :",
"title": "Heures de TP :",
"size": 4,
"type": "float",
"explanation": "nombre d'heures de Travaux Pratiques",
"explanation": "nombre d'heures de Travaux Pratiques (optionnel)",
},
),
]
if is_apc:
coefs_lst = a_module.ue_coefs_list()
if module:
coefs_lst = module.ue_coefs_list()
if coefs_lst:
coefs_descr_txt = ", ".join(
[f"{ue.acronyme}: {c}" for (ue, c) in coefs_lst]
@ -627,6 +721,18 @@ def module_edit(module_id=None):
},
)
]
else:
descr += [
(
"sep_ue_coefs",
{
"input_type": "separator",
"title": """
<div>(<em>les coefficients vers les UE se fixent sur la page dédiée</em>)
</div>""",
},
),
]
else: # Module classique avec coef scalaire:
descr += [
(
@ -641,7 +747,16 @@ def module_edit(module_id=None):
),
]
descr += [
("formation_id", {"input_type": "hidden"}),
(
"formation_id",
{
"input_type": "hidden",
"default": formation.id,
},
),
]
if module:
descr += [
("ue_id", {"input_type": "hidden"}),
("module_id", {"input_type": "hidden"}),
(
@ -650,9 +765,9 @@ def module_edit(module_id=None):
"input_type": "menu",
"title": "Rattachement :" if is_apc else "Matière :",
"explanation": (
"UE de rattachement"
"UE de rattachement, utilisée notamment pour les malus"
+ (
" module utilisé, ne peut pas être changé de semestre"
" (module utilisé, ne peut pas être changé de semestre)"
if in_use
else ""
)
@ -665,6 +780,31 @@ def module_edit(module_id=None):
},
),
]
else: # Création
if matiere_id:
descr += [
("ue_id", {"default": ue.id, "input_type": "hidden"}),
("matiere_id", {"default": matiere_id, "input_type": "hidden"}),
]
else:
# choix de l'UE de rattachement
descr += [
(
"ue_id",
{
"input_type": "menu",
"type": "int",
"title": "UE de rattachement",
"explanation": "utilisée notamment pour les malus",
"labels": [
f"S{u.semestre_idx if u.semestre_idx is not None else '.'} / {u.acronyme} {u.titre}"
for u in ues
],
"allowed_values": [u.id for u in ues],
},
),
]
if is_apc:
# le semestre du module est toujours celui de son UE
descr += [
@ -710,17 +850,56 @@ def module_edit(module_id=None):
"numero",
{
"size": 2,
"explanation": "numéro (1,2,3,4...) pour ordre d'affichage",
"explanation": "numéro (1, 2, 3, 4, ...) pour ordre d'affichage",
"type": "int",
"default": default_num,
},
),
]
# Choix des parcours
if is_apc:
ref_comp = formation.referentiel_competence
if ref_comp:
descr += [
(
"parcours",
{
"input_type": "checkbox",
"vertical": True,
"labels": [parcour.libelle for parcour in ref_comp.parcours],
"allowed_values": [
str(parcour.id) for parcour in ref_comp.parcours
],
"explanation": "parcours dans lesquels est utilisé ce module.",
},
)
]
if module:
module_dict["parcours"] = [
str(parcour.id) for parcour in module.parcours
]
else:
descr += [
(
"parcours",
{
"input_type": "separator",
"title": f"""<span class="fontred">Pas de parcours:
<a class="stdlink" href="{ url_for('notes.refcomp_assoc_formation',
scodoc_dept=g.scodoc_dept, formation_id=formation.id)
}">associer un référentiel de compétence</a>
</span>""",
},
)
]
# force module semestre_idx to its UE
if a_module.ue.semestre_idx:
module["semestre_id"] = a_module.ue.semestre_idx
if module:
if module.ue.semestre_idx is None:
# Filet de sécurité si jamais l'UE n'a pas non plus de semestre:
if not module["semestre_id"]:
module["semestre_id"] = 1
module_dict["semestre_id"] = 1
else:
module_dict["semestre_id"] = module.ue.semestre_idx
tf = TrivialFormulator(
request.base_url,
scu.get_request_args(),
@ -728,8 +907,9 @@ def module_edit(module_id=None):
html_foot_markup="""<div style="width: 90%;"><span class="sco_tag_edit"><textarea data-module_id="{}" class="module_tag_editor">{}</textarea></span></div>""".format(
module_id, ",".join(sco_tag_module.module_tag_list(module_id))
),
initvalues=module,
submitlabel="Modifier ce module",
initvalues=module_dict if module else {},
submitlabel="Modifier ce module" if module else "Créer ce module",
cancelbutton="Annuler",
)
#
if tf[0] == 0:
@ -739,21 +919,40 @@ def module_edit(module_id=None):
url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
formation_id=formation_id,
semestre_idx=module["semestre_id"],
formation_id=formation.id,
semestre_idx=orig_semestre_idx,
)
)
else:
if create:
if not matiere_id:
# formulaire avec choix UE de rattachement
ue = UniteEns.query.get(tf[2]["ue_id"])
if ue is None:
raise ValueError("UE invalide")
matiere = ue.matieres.first()
if matiere:
tf[2]["matiere_id"] = matiere.id
else:
matiere_id = sco_edit_matiere.do_matiere_create(
{"ue_id": ue.id, "titre": ue.titre, "numero": 1},
)
tf[2]["matiere_id"] = matiere_id
tf[2]["semestre_id"] = ue.semestre_idx
module_id = do_module_create(tf[2])
module = Module.query.get(module_id)
else: # EDITION MODULE
# l'UE de rattachement peut changer
tf[2]["ue_id"], tf[2]["matiere_id"] = tf[2]["ue_matiere_id"].split("!")
x, y = tf[2]["ue_matiere_id"].split("!")
tf[2]["ue_id"] = int(x)
tf[2]["matiere_id"] = int(y)
old_ue_id = a_module.ue.id
old_ue_id = module.ue.id
new_ue_id = tf[2]["ue_id"]
if (old_ue_id != new_ue_id) and in_use:
new_ue = UniteEns.query.get_or_404(new_ue_id)
if new_ue.semestre_idx != a_module.ue.semestre_idx:
if new_ue.semestre_idx != module.ue.semestre_idx:
# pas changer de semestre un module utilisé !
raise ScoValueError(
"Module utilisé: il ne peut pas être changé de semestre !"
@ -765,12 +964,21 @@ def module_edit(module_id=None):
raise ValueError("UE invalide")
tf[2]["semestre_id"] = selected_ue.semestre_idx
# Check unicité code module dans la formation
# ??? TODO
#
do_module_edit(tf[2])
# Modifie les parcours
module.parcours = [
ApcParcours.query.get(int(parcour_id_str))
for parcour_id_str in tf[2]["parcours"]
]
db.session.add(module)
db.session.commit()
return flask.redirect(
url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
formation_id=formation_id,
formation_id=formation.id,
semestre_idx=tf[2]["semestre_id"],
)
)

View File

@ -255,7 +255,9 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
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)
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"
@ -287,7 +289,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
f"""
<h4>UE du semestre S{ue.semestre_idx}</h4>
"""
if is_apc
if is_apc and ue
else "",
]
@ -1015,9 +1017,6 @@ def _ue_table_ues(
}">transformer en UE ordinaire</a>&nbsp;"""
)
H.append("</span>")
breakpoint()
if ue.niveau_competence is None:
H.append(" pas de compétence associée ")
ue_editable = editable and not ue_is_locked(ue["ue_id"])
if ue_editable:
H.append(

View File

@ -84,13 +84,15 @@
url_for("notes.module_create",
scodoc_dept=g.scodoc_dept,
module_type=module_type|int,
matiere_id=matiere_parent.id
matiere_id=matiere_parent.id,
semestre_id=semestre_id,
)}}"
{% else %}"{{
url_for("notes.module_create",
scodoc_dept=g.scodoc_dept,
module_type=module_type|int,
formation_id=formation.id
formation_id=formation.id,
semestre_id=semestre_id,
)}}"
{% endif %}
>{{create_element_msg}}</a>

View File

@ -22,13 +22,39 @@ def upgrade():
"notes_ue", sa.Column("niveau_competence_id", sa.Integer(), nullable=True)
)
op.create_foreign_key(
None, "notes_ue", "apc_niveau", ["niveau_competence_id"], ["id"]
"notes_ue_niveau_competence_id_fkey",
"notes_ue",
"apc_niveau",
["niveau_competence_id"],
["id"],
)
op.create_table(
"parcours_modules",
sa.Column("parcours_id", sa.Integer(), nullable=False),
sa.Column("module_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["module_id"],
["notes_modules.id"],
# nom ajouté manuellement:
name="parcours_modules_module_id_fkey",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["parcours_id"],
["apc_parcours.id"],
# nom ajouté manuellement:
name="parcours_modules_parcours_id_fkey",
),
sa.PrimaryKeyConstraint("parcours_id", "module_id"),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "notes_ue", type_="foreignkey")
op.drop_constraint(
"notes_ue_niveau_competence_id_fkey", "notes_ue", type_="foreignkey"
)
op.drop_column("notes_ue", "niveau_competence_id")
op.drop_table("parcours_modules")
# ### end Alembic commands ###

View File

@ -4,35 +4,57 @@ Utiliser par exemple comme:
pytest tests/unit/test_refcomp.py
"""
import io
from flask import g
import app
from app import db
from app import models
from app.but.import_refcomp import orebut_import_refcomp
from app.models import UniteEns
from app.models.but_refcomp import (
ApcReferentielCompetences,
ApcCompetence,
ApcSituationPro,
ApcNiveau,
)
from tests.unit import setup
REF_RT_XML = open(
"ressources/referentiels/but2022/competences/but-RT-05012022-081735.xml"
).read()
def test_but_refcomp(test_client):
"""modèles ref. comp."""
xml_data = open(
"ressources/referentiels/but2022/competences/but-RT-05012022-081735.xml"
).read()
dept_id = models.Departement.query.first().id
ref = orebut_import_refcomp(xml_data, dept_id)
assert ref.competences.count() == 13
assert ref.competences[0].situations.count() == 3
assert ref.competences[0].situations[0].libelle.startswith("Conception ")
ref_comp: ApcReferentielCompetences = orebut_import_refcomp(REF_RT_XML, dept_id)
assert ref_comp.competences.count() == 13
assert ref_comp.competences[0].situations.count() == 3
assert ref_comp.competences[0].situations[0].libelle.startswith("Conception ")
assert (
ref.competences[-1].situations[-1].libelle
ref_comp.competences[-1].situations[-1].libelle
== "Administration des services multimédia"
)
# test cascades on delete
db.session.delete(ref)
db.session.delete(ref_comp)
db.session.commit()
assert ApcCompetence.query.count() == 0
assert ApcSituationPro.query.count() == 0
def test_but_assoc_ue_parcours(test_client):
"""Association UE / Niveau compétence"""
dept_id = models.Departement.query.first().id
G, formation_id, (ue1_id, ue2_id, ue3_id), module_ids = setup.build_formation_test()
ref_comp: ApcReferentielCompetences = orebut_import_refcomp(REF_RT_XML, dept_id)
ue = UniteEns.query.get(ue1_id)
assert ue.niveau_competence is None
niveau = ApcNiveau.query.first()
ue.niveau_competence = niveau
db.session.add(ue)
db.session.commit()
ue = UniteEns.query.get(ue1_id)
assert ue.niveau_competence == niveau
assert len(niveau.ues) == 1
assert niveau.ues[0] == ue