forked from ScoDoc/ScoDoc
624 lines
20 KiB
Python
624 lines
20 KiB
Python
##############################################################################
|
|
# ScoDoc
|
|
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
|
# See LICENSE
|
|
##############################################################################
|
|
"""ScoDoc 9 models : Référentiel Compétence BUT 2021
|
|
"""
|
|
from datetime import datetime
|
|
|
|
import flask_sqlalchemy
|
|
from sqlalchemy.orm import class_mapper
|
|
import sqlalchemy
|
|
|
|
from app import db
|
|
|
|
from app.scodoc.sco_utils import ModuleType
|
|
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
|
|
|
|
|
|
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
|
|
def attribute_names(cls):
|
|
"liste ids (noms de colonnes) d'un modèle"
|
|
return [
|
|
prop.key
|
|
for prop in class_mapper(cls).iterate_properties
|
|
if isinstance(prop, sqlalchemy.orm.ColumnProperty)
|
|
]
|
|
|
|
|
|
class XMLModel:
|
|
"""Mixin class, to ease loading Orebut XMLs"""
|
|
|
|
_xml_attribs = {} # to be overloaded
|
|
id = "_"
|
|
|
|
@classmethod
|
|
def attr_from_xml(cls, args: dict) -> dict:
|
|
"""dict with attributes imported from Orébut XML
|
|
and renamed for our models.
|
|
The mapping is specified by the _xml_attribs
|
|
attribute in each model class.
|
|
Keep only attributes corresponding to columns in our model:
|
|
other XML attributes are simply ignored.
|
|
"""
|
|
columns = attribute_names(cls)
|
|
renamed_attributes = {cls._xml_attribs.get(k, k): v for (k, v) in args.items()}
|
|
return {k: renamed_attributes[k] for k in renamed_attributes if k in columns}
|
|
|
|
def __repr__(self):
|
|
return f'<{self.__class__.__name__} {self.id} "{self.titre if hasattr(self, "titre") else ""}">'
|
|
|
|
|
|
class ApcReferentielCompetences(db.Model, XMLModel):
|
|
"Référentiel de compétence d'une spécialité"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
dept_id = db.Column(
|
|
db.Integer, db.ForeignKey("departement.id", ondelete="CASCADE"), index=True
|
|
)
|
|
annexe = db.Column(db.Text()) # '1', '22', ...
|
|
specialite = db.Column(db.Text()) # 'CJ', 'RT', 'INFO', ...
|
|
specialite_long = db.Column(
|
|
db.Text()
|
|
) # 'Carrière Juridique', 'Réseaux et télécommunications', ...
|
|
type_titre = db.Column(db.Text()) # 'B.U.T.'
|
|
type_structure = db.Column(db.Text()) # 'type1', 'type2', ...
|
|
type_departement = db.Column(db.Text()) # "secondaire", "tertiaire"
|
|
version_orebut = db.Column(db.Text()) # '2021-12-11 00:00:00'
|
|
_xml_attribs = { # Orébut xml attrib : attribute
|
|
"type": "type_titre",
|
|
"version": "version_orebut",
|
|
}
|
|
# ScoDoc specific fields:
|
|
scodoc_date_loaded = db.Column(db.DateTime, default=datetime.utcnow)
|
|
scodoc_orig_filename = db.Column(db.Text())
|
|
# Relations:
|
|
competences = db.relationship(
|
|
"ApcCompetence",
|
|
backref="referentiel",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
parcours = db.relationship(
|
|
"ApcParcours",
|
|
backref="referentiel",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
formations = db.relationship(
|
|
"Formation",
|
|
backref="referentiel_competence",
|
|
order_by="Formation.acronyme, Formation.version",
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
|
|
|
|
def get_version(self) -> str:
|
|
"La version, normalement sous forme de date iso yyy-mm-dd"
|
|
if not self.version_orebut:
|
|
return ""
|
|
return self.version_orebut.split()[0]
|
|
|
|
def to_dict(self, parcours: list["ApcParcours"] = None, with_app_critiques=True):
|
|
"""Représentation complète du ref. de comp.
|
|
comme un dict.
|
|
Si parcours est une liste de parcours, restreint l'export aux parcours listés.
|
|
"""
|
|
return {
|
|
"dept_id": self.dept_id,
|
|
"annexe": self.annexe,
|
|
"specialite": self.specialite,
|
|
"specialite_long": self.specialite_long,
|
|
"type_structure": self.type_structure,
|
|
"type_departement": self.type_departement,
|
|
"type_titre": self.type_titre,
|
|
"version_orebut": self.version_orebut,
|
|
"scodoc_date_loaded": self.scodoc_date_loaded.isoformat() + "Z"
|
|
if self.scodoc_date_loaded
|
|
else "",
|
|
"scodoc_orig_filename": self.scodoc_orig_filename,
|
|
"competences": {
|
|
x.titre: x.to_dict(with_app_critiques=with_app_critiques)
|
|
for x in self.competences
|
|
},
|
|
"parcours": {
|
|
x.code: x.to_dict()
|
|
for x in (self.parcours if parcours is None else parcours)
|
|
},
|
|
}
|
|
|
|
def get_niveaux_by_parcours(
|
|
self, annee: int, parcour: "ApcParcours" = None
|
|
) -> tuple[list["ApcParcours"], dict]:
|
|
"""
|
|
Construit la liste des niveaux de compétences pour chaque parcours
|
|
de ce référentiel, ou seulement pour le parcours donné.
|
|
|
|
Les niveaux sont groupés par parcours, en isolant les niveaux de tronc commun.
|
|
|
|
Le tronc commun n'est pas identifié comme tel dans les référentiels Orébut:
|
|
on cherche les niveaux qui sont présents dans tous les parcours et les range sous
|
|
la clé "TC" (toujours présente mais éventuellement liste vide si pas de tronc commun).
|
|
|
|
Résultat: couple
|
|
( [ ApcParcours ],
|
|
{
|
|
"TC" : [ ApcNiveau ],
|
|
parcour.id : [ ApcNiveau ]
|
|
}
|
|
)
|
|
"""
|
|
parcours_ref = self.parcours.order_by(ApcParcours.numero).all()
|
|
if parcour is None:
|
|
parcours = parcours_ref
|
|
else:
|
|
parcours = [parcour]
|
|
niveaux_by_parcours = {
|
|
parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self)
|
|
for parcour in parcours_ref
|
|
}
|
|
# Cherche tronc commun
|
|
if niveaux_by_parcours:
|
|
niveaux_ids_tc = set.intersection(
|
|
*[
|
|
{n.id for n in niveaux_by_parcours[parcour_id]}
|
|
for parcour_id in niveaux_by_parcours
|
|
]
|
|
)
|
|
else:
|
|
niveaux_ids_tc = set()
|
|
# Enleve les niveaux du tronc commun
|
|
niveaux_by_parcours_no_tc = {
|
|
parcour.id: [
|
|
niveau
|
|
for niveau in niveaux_by_parcours[parcour.id]
|
|
if niveau.id not in niveaux_ids_tc
|
|
]
|
|
for parcour in parcours
|
|
}
|
|
# Niveaux du TC
|
|
niveaux_tc = []
|
|
if len(parcours):
|
|
niveaux_parcours_1 = niveaux_by_parcours[parcours[0].id]
|
|
niveaux_tc = [
|
|
niveau for niveau in niveaux_parcours_1 if niveau.id in niveaux_ids_tc
|
|
]
|
|
niveaux_by_parcours_no_tc["TC"] = niveaux_tc
|
|
return parcours, niveaux_by_parcours_no_tc
|
|
|
|
def get_competences_tronc_commun(self) -> list["ApcCompetence"]:
|
|
"""Liste des compétences communes à tous les parcours du référentiel."""
|
|
parcours = self.parcours.all()
|
|
if not parcours:
|
|
return []
|
|
|
|
ids = set.intersection(
|
|
*[
|
|
{competence.id for competence in parcour.query_competences()}
|
|
for parcour in parcours
|
|
]
|
|
)
|
|
return sorted(
|
|
[
|
|
competence
|
|
for competence in parcours[0].query_competences()
|
|
if competence.id in ids
|
|
],
|
|
key=lambda c: c.numero or 0,
|
|
)
|
|
|
|
def table_niveaux_parcours(self) -> dict:
|
|
"""Une table avec les parcours:années BUT et les niveaux
|
|
{ parcour_id : { 1 : { competence_id : ordre }}}
|
|
"""
|
|
parcours_info = {}
|
|
for parcour in self.parcours:
|
|
print(f"# Parcours {parcour.code}")
|
|
descr_parcour = {}
|
|
parcours_info[parcour.id] = descr_parcour
|
|
for annee in (1, 2, 3):
|
|
descr_parcour[annee] = {
|
|
niveau.competence.id: niveau.ordre
|
|
for niveau in ApcNiveau.niveaux_annee_de_parcours(
|
|
parcour, annee, self
|
|
)
|
|
}
|
|
|
|
return parcours_info
|
|
|
|
|
|
class ApcCompetence(db.Model, XMLModel):
|
|
"Compétence"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
referentiel_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
# les compétences dans Orébut sont identifiées par leur id unique
|
|
# (mais id_orebut n'est pas unique car le même ref. pourra être chargé dans plusieurs depts)
|
|
id_orebut = db.Column(db.Text(), nullable=True, index=True)
|
|
titre = db.Column(db.Text(), nullable=False, index=True)
|
|
titre_long = db.Column(db.Text())
|
|
couleur = db.Column(db.Text())
|
|
numero = db.Column(db.Integer) # ordre de présentation
|
|
_xml_attribs = { # xml_attrib : attribute
|
|
"id": "id_orebut",
|
|
"nom_court": "titre", # was name
|
|
"libelle_long": "titre_long",
|
|
}
|
|
situations = db.relationship(
|
|
"ApcSituationPro",
|
|
backref="competence",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
composantes_essentielles = db.relationship(
|
|
"ApcComposanteEssentielle",
|
|
backref="competence",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
niveaux = db.relationship(
|
|
"ApcNiveau",
|
|
backref="competence",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<ApcCompetence {self.id} {self.titre!r}>"
|
|
|
|
def to_dict(self, with_app_critiques=True):
|
|
"repr dict recursive sur situations, composantes, niveaux"
|
|
return {
|
|
"id_orebut": self.id_orebut,
|
|
"titre": self.titre,
|
|
"titre_long": self.titre_long,
|
|
"couleur": self.couleur,
|
|
"numero": self.numero,
|
|
"situations": [x.to_dict() for x in self.situations],
|
|
"composantes_essentielles": [
|
|
x.to_dict() for x in self.composantes_essentielles
|
|
],
|
|
"niveaux": {
|
|
x.annee: x.to_dict(with_app_critiques=with_app_critiques)
|
|
for x in self.niveaux
|
|
},
|
|
}
|
|
|
|
def to_dict_bul(self) -> dict:
|
|
"dict court pour bulletins"
|
|
return {
|
|
"id_orebut": self.id_orebut,
|
|
"titre": self.titre,
|
|
"titre_long": self.titre_long,
|
|
"couleur": self.couleur,
|
|
"numero": self.numero,
|
|
}
|
|
|
|
|
|
class ApcSituationPro(db.Model, XMLModel):
|
|
"Situation professionnelle"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
competence_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
libelle = db.Column(db.Text(), nullable=False)
|
|
# aucun attribut (le text devient le libellé)
|
|
def to_dict(self):
|
|
return {"libelle": self.libelle}
|
|
|
|
|
|
class ApcComposanteEssentielle(db.Model, XMLModel):
|
|
"Composante essentielle"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
competence_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
libelle = db.Column(db.Text(), nullable=False)
|
|
|
|
def to_dict(self):
|
|
return {"libelle": self.libelle}
|
|
|
|
|
|
class ApcNiveau(db.Model, XMLModel):
|
|
"""Niveau de compétence
|
|
Chaque niveau peut être associé à deux UE,
|
|
des semestres impair et pair de la même année.
|
|
"""
|
|
|
|
__tablename__ = "apc_niveau"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
competence_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
libelle = db.Column(db.Text(), nullable=False)
|
|
annee = db.Column(db.Text(), nullable=False) # "BUT1", "BUT2", "BUT3"
|
|
# L'ordre est le niveau (1,2,3) ou (1,2) suivant la competence
|
|
ordre = db.Column(db.Integer, nullable=False) # 1, 2, 3
|
|
app_critiques = db.relationship(
|
|
"ApcAppCritique",
|
|
backref="niveau",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
ues = db.relationship("UniteEns", back_populates="niveau_competence")
|
|
|
|
def __repr__(self):
|
|
return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={
|
|
self.annee!r} {self.competence!r}>"""
|
|
|
|
def to_dict(self, with_app_critiques=True):
|
|
"as a dict, recursif (ou non) sur les AC"
|
|
return {
|
|
"libelle": self.libelle,
|
|
"annee": self.annee,
|
|
"ordre": self.ordre,
|
|
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques}
|
|
if with_app_critiques
|
|
else {},
|
|
}
|
|
|
|
def to_dict_bul(self):
|
|
"dict pour bulletins: indique la compétence, pas les ACs (pour l'instant ?)"
|
|
return {
|
|
"libelle": self.libelle,
|
|
"annee": self.annee,
|
|
"ordre": self.ordre,
|
|
"competence": self.competence.to_dict_bul(),
|
|
}
|
|
|
|
@classmethod
|
|
def niveaux_annee_de_parcours(
|
|
cls,
|
|
parcour: "ApcParcours",
|
|
annee: int,
|
|
referentiel_competence: ApcReferentielCompetences = None,
|
|
) -> list["ApcNiveau"]:
|
|
"""Les niveaux de l'année du parcours
|
|
Si le parcour est None, tous les niveaux de l'année
|
|
"""
|
|
if annee not in {1, 2, 3}:
|
|
raise ValueError("annee invalide pour un parcours BUT")
|
|
referentiel_competence = (
|
|
parcour.referentiel if parcour else referentiel_competence
|
|
)
|
|
if referentiel_competence is None:
|
|
raise ScoNoReferentielCompetences()
|
|
if not parcour:
|
|
annee_formation = f"BUT{annee}"
|
|
return ApcNiveau.query.filter(
|
|
ApcNiveau.annee == annee_formation,
|
|
ApcCompetence.id == ApcNiveau.competence_id,
|
|
ApcCompetence.referentiel_id == referentiel_competence.id,
|
|
)
|
|
annee_parcour = parcour.annees.filter_by(ordre=annee).first()
|
|
if not annee_parcour:
|
|
return []
|
|
|
|
parcour_niveaux: list[
|
|
ApcParcoursNiveauCompetence
|
|
] = annee_parcour.niveaux_competences
|
|
niveaux: list[ApcNiveau] = [
|
|
pn.competence.niveaux.filter_by(ordre=pn.niveau).first()
|
|
for pn in parcour_niveaux
|
|
]
|
|
return niveaux
|
|
|
|
|
|
app_critiques_modules = db.Table(
|
|
"apc_modules_acs",
|
|
db.Column(
|
|
"module_id",
|
|
db.ForeignKey("notes_modules.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
),
|
|
db.Column(
|
|
"app_crit_id",
|
|
db.ForeignKey("apc_app_critique.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
),
|
|
)
|
|
|
|
|
|
class ApcAppCritique(db.Model, XMLModel):
|
|
"Apprentissage Critique BUT"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
niveau_id = db.Column(
|
|
db.Integer, db.ForeignKey("apc_niveau.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
code = db.Column(db.Text(), nullable=False, index=True)
|
|
libelle = db.Column(db.Text())
|
|
|
|
# modules = db.relationship(
|
|
# "Module",
|
|
# secondary="apc_modules_acs",
|
|
# lazy="dynamic",
|
|
# backref=db.backref("app_critiques", lazy="dynamic"),
|
|
# )
|
|
|
|
@classmethod
|
|
def app_critiques_ref_comp(
|
|
cls,
|
|
ref_comp: ApcReferentielCompetences,
|
|
annee: str,
|
|
competence: ApcCompetence = None,
|
|
) -> flask_sqlalchemy.BaseQuery:
|
|
"Liste les AC de tous les parcours de ref_comp pour l'année indiquée"
|
|
assert annee in {"BUT1", "BUT2", "BUT3"}
|
|
query = cls.query.filter(
|
|
ApcAppCritique.niveau_id == ApcNiveau.id,
|
|
ApcNiveau.competence_id == ApcCompetence.id,
|
|
ApcNiveau.annee == annee,
|
|
ApcCompetence.referentiel_id == ref_comp.id,
|
|
)
|
|
if competence is not None:
|
|
query = query.filter(ApcNiveau.competence == competence)
|
|
return query
|
|
|
|
def to_dict(self, with_code=False) -> dict:
|
|
if with_code:
|
|
return {"code": self.code, "libelle": self.libelle}
|
|
return {"libelle": self.libelle}
|
|
|
|
def get_label(self) -> str:
|
|
return self.code + " - " + self.titre
|
|
|
|
def __repr__(self):
|
|
return f"<{self.__class__.__name__} {self.code!r}>"
|
|
|
|
def get_saes(self):
|
|
"""Liste des SAE associées"""
|
|
return [m for m in self.modules if m.module_type == ModuleType.SAE]
|
|
|
|
|
|
parcours_modules = db.Table(
|
|
"parcours_modules",
|
|
db.Column(
|
|
"parcours_id",
|
|
db.Integer,
|
|
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
|
|
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)"""
|
|
|
|
parcours_formsemestre = db.Table(
|
|
"parcours_formsemestre",
|
|
db.Column(
|
|
"parcours_id",
|
|
db.Integer,
|
|
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
),
|
|
db.Column(
|
|
"formsemestre_id",
|
|
db.Integer,
|
|
db.ForeignKey("notes_formsemestre.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
),
|
|
)
|
|
"""Association parcours <-> formsemestre (many-to-many)"""
|
|
|
|
|
|
class ApcParcours(db.Model, XMLModel):
|
|
"Un parcours BUT"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
referentiel_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
numero = db.Column(db.Integer) # ordre de présentation
|
|
code = db.Column(db.Text(), nullable=False)
|
|
libelle = db.Column(db.Text(), nullable=False)
|
|
annees = db.relationship(
|
|
"ApcAnneeParcours",
|
|
backref="parcours",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
ues = db.relationship("UniteEns", back_populates="parcour")
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<{self.__class__.__name__} {self.id} {self.code!r} ref={self.referentiel}>"
|
|
|
|
def to_dict(self, with_annees=True) -> dict:
|
|
"""dict repr.
|
|
On peut ne pas indiquer les années pour gagner de la place (export formations).
|
|
"""
|
|
d = {
|
|
"code": self.code,
|
|
"numero": self.numero,
|
|
"libelle": self.libelle,
|
|
}
|
|
if with_annees:
|
|
d["annees"] = {x.ordre: x.to_dict() for x in self.annees}
|
|
return d
|
|
|
|
def query_competences(self) -> flask_sqlalchemy.BaseQuery:
|
|
"Les compétences associées à ce parcours"
|
|
return (
|
|
ApcCompetence.query.join(ApcParcoursNiveauCompetence, ApcAnneeParcours)
|
|
.filter_by(parcours_id=self.id)
|
|
.order_by(ApcCompetence.numero)
|
|
)
|
|
|
|
|
|
class ApcAnneeParcours(db.Model, XMLModel):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
parcours_id = db.Column(
|
|
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
ordre = db.Column(db.Integer)
|
|
"numéro de l'année: 1, 2, 3"
|
|
|
|
def __repr__(self):
|
|
return f"<{self.__class__.__name__} {self.id} ordre={self.ordre!r} parcours={self.parcours.code!r}>"
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"ordre": self.ordre,
|
|
"competences": {
|
|
x.competence.titre: {
|
|
"niveau": x.niveau,
|
|
"id_orebut": x.competence.id_orebut,
|
|
}
|
|
for x in self.niveaux_competences
|
|
},
|
|
}
|
|
|
|
|
|
class ApcParcoursNiveauCompetence(db.Model):
|
|
"""Association entre année de parcours et compétence.
|
|
Le "niveau" de la compétence est donné ici
|
|
(convention Orébut)
|
|
"""
|
|
|
|
competence_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
annee_parcours_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_annee_parcours.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
niveau = db.Column(db.Integer, nullable=False) # 1, 2, 3
|
|
competence = db.relationship(
|
|
ApcCompetence,
|
|
backref=db.backref(
|
|
"annee_parcours",
|
|
passive_deletes=True,
|
|
cascade="save-update, merge, delete, delete-orphan",
|
|
lazy="dynamic",
|
|
),
|
|
)
|
|
annee_parcours = db.relationship(
|
|
ApcAnneeParcours,
|
|
backref=db.backref(
|
|
"niveaux_competences",
|
|
passive_deletes=True,
|
|
cascade="save-update, merge, delete, delete-orphan",
|
|
),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<{self.__class__.__name__} {self.competence!r}<->{self.annee_parcours!r} niveau={self.niveau!r}>"
|