forked from ScoDoc/ScoDoc
1696 lines
65 KiB
Python
1696 lines
65 KiB
Python
# -*- coding: UTF-8 -*
|
|
##############################################################################
|
|
# ScoDoc
|
|
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
|
# See LICENSE
|
|
##############################################################################
|
|
|
|
# pylint génère trop de faux positifs avec les colonnes date:
|
|
# pylint: disable=no-member,not-an-iterable
|
|
|
|
"""ScoDoc models: formsemestre
|
|
"""
|
|
from collections import defaultdict
|
|
import datetime
|
|
from functools import cached_property
|
|
from itertools import chain
|
|
from operator import attrgetter
|
|
|
|
from flask_login import current_user
|
|
|
|
from flask import abort, flash, g, url_for
|
|
from flask_sqlalchemy.query import Query
|
|
from sqlalchemy.sql import text
|
|
from sqlalchemy import func
|
|
|
|
import app.scodoc.sco_utils as scu
|
|
from app import db, email, log
|
|
from app.auth.models import User
|
|
from app import models
|
|
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
|
|
from app.models.but_refcomp import (
|
|
ApcParcours,
|
|
ApcReferentielCompetences,
|
|
parcours_formsemestre,
|
|
)
|
|
from app.models.config import ScoDocSiteConfig
|
|
from app.models.departements import Departement
|
|
from app.models.etudiants import Identite
|
|
from app.models.evaluations import Evaluation
|
|
from app.models.events import Scolog, ScolarNews
|
|
from app.models.formations import Formation
|
|
from app.models.groups import GroupDescr, Partition
|
|
from app.models.moduleimpls import (
|
|
ModuleImpl,
|
|
ModuleImplInscription,
|
|
notes_modules_enseignants,
|
|
)
|
|
from app.models.modules import Module
|
|
from app.models.scolar_event import ScolarEvent
|
|
from app.models.ues import UniteEns
|
|
from app.models.validations import ScolarFormSemestreValidation
|
|
from app.scodoc import codes_cursus, sco_cache, sco_preferences
|
|
from app.scodoc.sco_exceptions import ScoValueError
|
|
from app.scodoc.sco_permissions import Permission
|
|
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric
|
|
from app.scodoc.sco_vdi import ApoEtapeVDI
|
|
|
|
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
|
|
|
|
|
|
class FormSemestre(models.ScoDocModel):
|
|
"""Mise en oeuvre d'un semestre de formation"""
|
|
|
|
__tablename__ = "notes_formsemestre"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
formsemestre_id = db.synonym("id")
|
|
# dept_id est aussi dans la formation, ajouté ici pour
|
|
# simplifier et accélérer les selects dans notesdb
|
|
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
|
|
formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
|
|
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
|
|
titre = db.Column(db.Text(), nullable=False)
|
|
# nb max d'inscriptions (non DEM), null si illimité:
|
|
capacite_accueil = db.Column(db.Integer, nullable=True)
|
|
date_debut = db.Column(db.Date(), nullable=False)
|
|
date_fin = db.Column(db.Date(), nullable=False) # jour inclus
|
|
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
|
|
"identifiant emplois du temps (unicité non imposée)"
|
|
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
|
|
"False si verrouillé"
|
|
modalite = db.Column(
|
|
db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite")
|
|
)
|
|
"Modalité de formation: 'FI', 'FAP', 'FC', ..."
|
|
gestion_compensation = db.Column(
|
|
db.Boolean(), nullable=False, default=False, server_default="false"
|
|
)
|
|
"gestion compensation sem DUT (inutilisé en APC)"
|
|
bul_hide_xml = db.Column(
|
|
db.Boolean(), nullable=False, default=False, server_default="false"
|
|
)
|
|
"ne publie pas le bulletin sur l'API"
|
|
block_moyennes = db.Column(
|
|
db.Boolean(), nullable=False, default=False, server_default="false"
|
|
)
|
|
"Bloque le calcul des moyennes (générale et d'UE)"
|
|
block_moyenne_generale = db.Column(
|
|
db.Boolean(), nullable=False, default=False, server_default="false"
|
|
)
|
|
"Si vrai, la moyenne générale indicative BUT n'est pas calculée"
|
|
mode_calcul_moyennes = db.Column(
|
|
db.Integer, nullable=False, default=0, server_default="0"
|
|
)
|
|
"pour usage futur"
|
|
gestion_semestrielle = db.Column(
|
|
db.Boolean(), nullable=False, default=False, server_default="false"
|
|
)
|
|
"Semestres décalés (pour gestion jurys DUT, pas implémenté ou utile en BUT)"
|
|
bul_bgcolor = db.Column(
|
|
db.String(SHORT_STR_LEN),
|
|
default="white",
|
|
server_default="white",
|
|
nullable=False,
|
|
)
|
|
"couleur fond bulletins HTML"
|
|
resp_can_edit = db.Column(
|
|
db.Boolean(), nullable=False, default=False, server_default="false"
|
|
)
|
|
"autorise resp. à modifier le formsemestre"
|
|
resp_can_change_ens = db.Column(
|
|
db.Boolean(), nullable=False, default=True, server_default="true"
|
|
)
|
|
"autorise resp. a modifier slt les enseignants"
|
|
ens_can_edit_eval = db.Column(
|
|
db.Boolean(), nullable=False, default=False, server_default="False"
|
|
)
|
|
"autorise les enseignants à créer des évals dans leurs modimpls"
|
|
elt_sem_apo = db.Column(db.Text()) # peut être fort long !
|
|
"code element semestre Apogée, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'"
|
|
elt_annee_apo = db.Column(db.Text())
|
|
"code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'"
|
|
elt_passage_apo = db.Column(db.Text())
|
|
"code element passage Apogée"
|
|
|
|
# Data pour groups_auto_assignment
|
|
# (ce champ est utilisé uniquement via l'API par le front js)
|
|
groups_auto_assignment_data = db.Column(db.LargeBinary(), nullable=True)
|
|
|
|
# Relations:
|
|
etapes = db.relationship(
|
|
"FormSemestreEtape", cascade="all,delete-orphan", backref="formsemestre"
|
|
)
|
|
modimpls = db.relationship(
|
|
"ModuleImpl",
|
|
backref="formsemestre",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
description = db.relationship(
|
|
"FormSemestreDescription",
|
|
back_populates="formsemestre",
|
|
cascade="all, delete-orphan",
|
|
uselist=False,
|
|
)
|
|
etuds = db.relationship(
|
|
"Identite",
|
|
secondary="notes_formsemestre_inscription",
|
|
viewonly=True,
|
|
lazy="dynamic",
|
|
)
|
|
responsables = db.relationship(
|
|
"User",
|
|
secondary="notes_formsemestre_responsables",
|
|
lazy=True,
|
|
backref=db.backref("formsemestres", lazy=True),
|
|
order_by=func.upper(User.nom),
|
|
)
|
|
partitions = db.relationship(
|
|
"Partition",
|
|
backref=db.backref("formsemestre", lazy=True),
|
|
lazy="dynamic",
|
|
order_by="Partition.numero",
|
|
)
|
|
# Ancien id ScoDoc7 pour les migrations de bases anciennes
|
|
# ne pas utiliser après migrate_scodoc7_dept_archives
|
|
scodoc7_id = db.Column(db.Text(), nullable=True)
|
|
|
|
# BUT
|
|
parcours = db.relationship(
|
|
"ApcParcours",
|
|
secondary=parcours_formsemestre,
|
|
lazy="subquery",
|
|
backref=db.backref("formsemestres", lazy=True),
|
|
order_by=(ApcParcours.numero, ApcParcours.code),
|
|
)
|
|
|
|
def __init__(self, **kwargs):
|
|
super(FormSemestre, self).__init__(**kwargs)
|
|
if self.modalite is None:
|
|
self.modalite = FormationModalite.DEFAULT_MODALITE
|
|
|
|
def __repr__(self):
|
|
return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>"
|
|
|
|
def html_link_status(self, label=None, title=None) -> str:
|
|
"html link to status page"
|
|
return f"""<a class="stdlink" href="{
|
|
url_for("notes.formsemestre_status", scodoc_dept=self.departement.acronym,
|
|
formsemestre_id=self.id,)
|
|
}" title="{title or ''}">{label or self.titre_mois()}</a>
|
|
"""
|
|
|
|
@classmethod
|
|
def get_formsemestre(
|
|
cls, formsemestre_id: int | str, dept_id: int = None, accept_none=False
|
|
) -> "FormSemestre | None":
|
|
"""FormSemestre ou 404 (ou None si accept_none), cherche uniquement dans
|
|
le département spécifié ou le courant (g.scodoc_dept).
|
|
Si accept_none, return None si l'id est invalide ou ne correspond
|
|
pas à un formsemestre.
|
|
"""
|
|
if not isinstance(formsemestre_id, int):
|
|
try:
|
|
formsemestre_id = int(formsemestre_id)
|
|
except (TypeError, ValueError):
|
|
if accept_none:
|
|
return None
|
|
abort(404, "formsemestre_id invalide")
|
|
|
|
dept_id = (
|
|
dept_id
|
|
if dept_id is not None
|
|
else (g.scodoc_dept_id if g.scodoc_dept else None)
|
|
)
|
|
|
|
query = (
|
|
cls.query.filter_by(id=formsemestre_id)
|
|
if dept_id is None
|
|
else cls.query.filter_by(id=formsemestre_id, dept_id=dept_id)
|
|
)
|
|
return query.first() if accept_none else query.first_or_404()
|
|
|
|
@classmethod
|
|
def create_formsemestre(cls, args: dict, silent=False) -> "FormSemestre":
|
|
"""Création d'un formsemestre, avec toutes les valeurs par défaut
|
|
et notification (sauf si silent).
|
|
Crée la partition par défaut.
|
|
"""
|
|
# was sco_formsemestre.do_formsemestre_create
|
|
if "dept_id" not in args:
|
|
args["dept_id"] = g.scodoc_dept_id
|
|
formsemestre: "FormSemestre" = cls.create_from_dict(args)
|
|
db.session.flush()
|
|
for etape in args.get("etapes") or []:
|
|
formsemestre.add_etape(etape)
|
|
db.session.commit()
|
|
# create default partition
|
|
partition = Partition(
|
|
formsemestre=formsemestre, partition_name=None, numero=1000000
|
|
)
|
|
db.session.add(partition)
|
|
partition.create_group(default=True)
|
|
db.session.commit()
|
|
|
|
if not silent:
|
|
url = url_for(
|
|
"notes.formsemestre_status",
|
|
scodoc_dept=formsemestre.departement.acronym,
|
|
formsemestre_id=formsemestre.id,
|
|
)
|
|
ScolarNews.add(
|
|
typ=ScolarNews.NEWS_SEM,
|
|
text=f"""Création du semestre <a href="{url}">{formsemestre.titre}</a>""",
|
|
url=url,
|
|
max_frequency=0,
|
|
)
|
|
|
|
return formsemestre
|
|
|
|
@classmethod
|
|
def convert_dict_fields(cls, args: dict) -> dict:
|
|
"""Convert fields in the given dict.
|
|
args: dict with args in application.
|
|
returns: dict to store in model's db.
|
|
"""
|
|
if "date_debut" in args:
|
|
args["date_debut"] = scu.convert_fr_date(args["date_debut"])
|
|
if "date_fin" in args:
|
|
args["date_fin"] = scu.convert_fr_date(args["date_fin"])
|
|
if "etat" in args:
|
|
if args["etat"] is None:
|
|
del args["etat"]
|
|
else:
|
|
args["etat"] = bool(args["etat"])
|
|
if "bul_bgcolor" in args:
|
|
args["bul_bgcolor"] = args.get("bul_bgcolor") or "white"
|
|
if "titre" in args:
|
|
args["titre"] = args.get("titre") or "sans titre"
|
|
if "capacite_accueil" in args: # peut être un nombre, "" ou None
|
|
try:
|
|
args["capacite_accueil"] = (
|
|
int(args["capacite_accueil"])
|
|
if args["capacite_accueil"] not in ("", None)
|
|
else None
|
|
)
|
|
except ValueError as exc:
|
|
raise ScoValueError("capacite_accueil invalide") from exc
|
|
if "responsables" in args: # peut être liste d'uid ou de user_name ou de User
|
|
resp_users = [User.get_user(u) for u in args["responsables"]]
|
|
args["responsables"] = [u for u in resp_users if u is not None]
|
|
return args
|
|
|
|
@classmethod
|
|
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
|
|
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
|
|
Add 'etapes' to excluded."""
|
|
# on ne peut pas affecter directement etapes
|
|
return super().filter_model_attributes(data, (excluded or set()) | {"etapes"})
|
|
|
|
def sort_key(self) -> tuple:
|
|
"""clé pour tris par ordre de date_debut, le plus ancien en tête
|
|
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
|
|
return (self.date_debut, self.semestre_id)
|
|
|
|
def to_dict(self, convert_objects=False) -> dict:
|
|
"""dict (compatible ScoDoc7).
|
|
If convert_objects, convert all attributes to native types
|
|
(suitable jor json encoding).
|
|
"""
|
|
d = dict(self.__dict__)
|
|
d.pop("_sa_instance_state", None)
|
|
d.pop("groups_auto_assignment_data", None)
|
|
# ScoDoc7 output_formators: (backward compat)
|
|
d["formsemestre_id"] = self.id
|
|
d["titre_num"] = self.titre_num()
|
|
if self.date_debut:
|
|
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
|
|
d["date_debut_iso"] = self.date_debut.isoformat()
|
|
else:
|
|
d["date_debut"] = d["date_debut_iso"] = ""
|
|
if self.date_fin:
|
|
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
|
|
d["date_fin_iso"] = self.date_fin.isoformat()
|
|
else:
|
|
d["date_fin"] = d["date_fin_iso"] = ""
|
|
d["responsables"] = [u.id for u in self.responsables]
|
|
d["titre_formation"] = self.titre_formation()
|
|
if convert_objects: # pour API
|
|
d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
|
|
d["departement"] = self.departement.to_dict()
|
|
d["formation"] = self.formation.to_dict()
|
|
d["etape_apo"] = self.etapes_apo_str()
|
|
else:
|
|
# Converti les étapes Apogee sous forme d'ApoEtapeVDI (compat scodoc7)
|
|
d["etapes"] = [e.as_apovdi() for e in self.etapes]
|
|
return d
|
|
|
|
def to_dict_api(self):
|
|
"""
|
|
Un dict avec les informations sur le semestre destinées à l'api
|
|
"""
|
|
d = dict(self.__dict__)
|
|
d.pop("_sa_instance_state", None)
|
|
d.pop("groups_auto_assignment_data", None)
|
|
d["annee_scolaire"] = self.annee_scolaire()
|
|
d["bul_hide_xml"] = self.bul_hide_xml
|
|
if self.date_debut:
|
|
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
|
|
d["date_debut_iso"] = self.date_debut.isoformat()
|
|
else:
|
|
d["date_debut"] = d["date_debut_iso"] = ""
|
|
if self.date_fin:
|
|
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
|
|
d["date_fin_iso"] = self.date_fin.isoformat()
|
|
else:
|
|
d["date_fin"] = d["date_fin_iso"] = ""
|
|
d["departement"] = self.departement.to_dict()
|
|
d["etape_apo"] = self.etapes_apo_str()
|
|
d["formsemestre_id"] = self.id
|
|
d["formation"] = self.formation.to_dict()
|
|
d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
|
|
d["responsables"] = [u.id for u in self.responsables]
|
|
d["titre_court"] = self.formation.acronyme
|
|
d["titre_formation"] = self.titre_formation()
|
|
d["titre_num"] = self.titre_num()
|
|
d["session_id"] = self.session_id()
|
|
return d
|
|
|
|
def get_default_group(self) -> GroupDescr:
|
|
"""default ('tous') group.
|
|
Le groupe par défaut contient tous les étudiants et existe toujours.
|
|
C'est l'unique groupe de la partition sans nom.
|
|
"""
|
|
default_partition = self.partitions.filter_by(partition_name=None).first()
|
|
if default_partition:
|
|
return default_partition.groups.first()
|
|
raise ScoValueError("Le semestre n'a pas de groupe par défaut")
|
|
|
|
def get_edt_ids(self) -> list[str]:
|
|
"""Les ids pour l'emploi du temps: à défaut, les codes étape Apogée.
|
|
Les edt_id de formsemestres ne sont pas normalisés afin de contrôler
|
|
précisément l'accès au fichier ics.
|
|
"""
|
|
return (
|
|
scu.split_id(self.edt_id)
|
|
or [e.etape_apo.strip() for e in self.etapes if e.etape_apo]
|
|
or []
|
|
)
|
|
|
|
def get_infos_dict(self) -> dict:
|
|
"""Un dict avec des informations sur le semestre
|
|
pour les bulletins et autres templates
|
|
(contenu compatible scodoc7 / anciens templates)
|
|
"""
|
|
d = self.to_dict()
|
|
d["anneescolaire"] = self.annee_scolaire_str()
|
|
d["annee_debut"] = str(self.date_debut.year)
|
|
d["annee"] = d["annee_debut"]
|
|
d["annee_fin"] = str(self.date_fin.year)
|
|
if d["annee_fin"] != d["annee_debut"]:
|
|
d["annee"] += "-" + str(d["annee_fin"])
|
|
d["mois_debut_ord"] = self.date_debut.month
|
|
d["mois_fin_ord"] = self.date_fin.month
|
|
# La période: considère comme "S1" (ou S3) les débuts en aout-sept-octobre
|
|
# devrait sans doute pouvoir etre changé... XXX PIVOT
|
|
d["periode"] = self.periode()
|
|
if self.date_debut.month >= 8 and self.date_debut.month <= 10:
|
|
d["periode"] = 1 # typiquement, début en septembre: S1, S3...
|
|
else:
|
|
d["periode"] = 2 # typiquement, début en février: S2, S4...
|
|
d["titreannee"] = self.titre_annee()
|
|
d["mois_debut"] = self.mois_debut()
|
|
d["mois_fin"] = self.mois_fin()
|
|
d["titremois"] = "%s %s (%s - %s)" % (
|
|
d["titre_num"],
|
|
self.modalite or "",
|
|
d["mois_debut"],
|
|
d["mois_fin"],
|
|
)
|
|
d["session_id"] = self.session_id()
|
|
d["etapes"] = self.etapes_apo_vdi()
|
|
d["etapes_apo_str"] = self.etapes_apo_str()
|
|
return d
|
|
|
|
def flip_lock(self):
|
|
"""Flip etat (lock)"""
|
|
self.etat = not self.etat
|
|
db.session.add(self)
|
|
|
|
def get_parcours_apc(self) -> list[ApcParcours]:
|
|
"""Liste des parcours proposés par ce semestre.
|
|
Si aucun n'est coché et qu'il y a un référentiel, tous ceux du référentiel.
|
|
"""
|
|
r = self.parcours or (
|
|
self.formation.referentiel_competence
|
|
and self.formation.referentiel_competence.parcours
|
|
)
|
|
return r or []
|
|
|
|
def get_ues(self, with_sport=False) -> list[UniteEns]:
|
|
"""UE des modules de ce semestre, triées par numéro.
|
|
- Formations classiques: les UEs auxquelles appartiennent
|
|
les modules mis en place dans ce semestre.
|
|
- Formations APC / BUT: les UEs de la formation qui
|
|
- ont le même numéro de semestre que ce formsemestre;
|
|
- et sont associées à l'un des parcours de ce formsemestre
|
|
(ou à aucun, donc tronc commun).
|
|
"""
|
|
# per-request caching
|
|
key = (self.id, with_sport)
|
|
_cache = getattr(g, "_formsemestre_get_ues_cache", None)
|
|
if _cache:
|
|
result = _cache.get(key, False)
|
|
if result is not False:
|
|
return result
|
|
else:
|
|
g._formsemestre_get_ues_cache = {}
|
|
_cache = g._formsemestre_get_ues_cache
|
|
|
|
formation: Formation = self.formation
|
|
if formation.is_apc():
|
|
# UEs de tronc commun (sans parcours indiqué)
|
|
sem_ues = {
|
|
ue.id: ue
|
|
for ue in formation.query_ues_parcour(
|
|
None, with_sport=with_sport
|
|
).filter(UniteEns.semestre_idx == self.semestre_id)
|
|
}
|
|
# Ajoute les UE de parcours
|
|
for parcour in self.parcours:
|
|
sem_ues.update(
|
|
{
|
|
ue.id: ue
|
|
for ue in formation.query_ues_parcour(
|
|
parcour, with_sport=with_sport
|
|
).filter(UniteEns.semestre_idx == self.semestre_id)
|
|
}
|
|
)
|
|
ues = sorted(sem_ues.values(), key=attrgetter("numero", "acronyme"))
|
|
else:
|
|
sem_ues = db.session.query(UniteEns).filter(
|
|
ModuleImpl.formsemestre_id == self.id,
|
|
Module.id == ModuleImpl.module_id,
|
|
UniteEns.id == Module.ue_id,
|
|
)
|
|
if not with_sport:
|
|
sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT)
|
|
ues = sem_ues.order_by(UniteEns.numero, UniteEns.acronyme).all()
|
|
_cache[key] = ues
|
|
return ues
|
|
|
|
@classmethod
|
|
def get_user_formsemestres_annee_by_dept(
|
|
cls, user: User
|
|
) -> tuple[
|
|
defaultdict[int, list["FormSemestre"]], defaultdict[int, list[ModuleImpl]]
|
|
]:
|
|
"""Liste des formsemestres de l'année scolaire
|
|
dans lesquels user intervient (comme resp., resp. de module ou enseignant),
|
|
ainsi que la liste des modimpls concernés dans chaque formsemestre
|
|
Attention: les semestres et modimpls peuvent être de différents départements !
|
|
Résultat:
|
|
{ dept_id : [ formsemestre, ... ] },
|
|
{ formsemestre_id : [ modimpl, ... ]}
|
|
"""
|
|
debut_annee_scolaire = scu.date_debut_annee_scolaire()
|
|
fin_annee_scolaire = scu.date_fin_annee_scolaire()
|
|
|
|
query = FormSemestre.query.filter(
|
|
FormSemestre.date_fin >= debut_annee_scolaire,
|
|
FormSemestre.date_debut < fin_annee_scolaire,
|
|
)
|
|
# responsable ?
|
|
formsemestres_resp = query.join(notes_formsemestre_responsables).filter_by(
|
|
responsable_id=user.id
|
|
)
|
|
# Responsable d'un modimpl ?
|
|
modimpls_resp = (
|
|
ModuleImpl.query.filter_by(responsable_id=user.id)
|
|
.join(FormSemestre)
|
|
.filter(
|
|
FormSemestre.date_fin >= debut_annee_scolaire,
|
|
FormSemestre.date_debut < fin_annee_scolaire,
|
|
)
|
|
)
|
|
# Enseignant dans un modimpl ?
|
|
modimpls_ens = (
|
|
ModuleImpl.query.join(notes_modules_enseignants)
|
|
.filter_by(ens_id=user.id)
|
|
.join(FormSemestre)
|
|
.filter(
|
|
FormSemestre.date_fin >= debut_annee_scolaire,
|
|
FormSemestre.date_debut < fin_annee_scolaire,
|
|
)
|
|
)
|
|
# Liste les modimpls, uniques
|
|
modimpls = modimpls_resp.all()
|
|
ids = {modimpl.id for modimpl in modimpls}
|
|
for modimpl in modimpls_ens:
|
|
if modimpl.id not in ids:
|
|
modimpls.append(modimpl)
|
|
ids.add(modimpl.id)
|
|
# Liste les formsemestres et modimpls associés
|
|
modimpls_by_formsemestre = defaultdict(lambda: [])
|
|
formsemestres = formsemestres_resp.all()
|
|
ids = {formsemestre.id for formsemestre in formsemestres}
|
|
for modimpl in chain(modimpls_resp, modimpls_ens):
|
|
if modimpl.formsemestre_id not in ids:
|
|
formsemestres.append(modimpl.formsemestre)
|
|
ids.add(modimpl.formsemestre_id)
|
|
modimpls_by_formsemestre[modimpl.formsemestre_id].append(modimpl)
|
|
# Tris et organisation par département
|
|
formsemestres_by_dept = defaultdict(lambda: [])
|
|
formsemestres.sort(key=lambda x: (x.departement.acronym,) + x.sort_key())
|
|
for formsemestre in formsemestres:
|
|
formsemestres_by_dept[formsemestre.dept_id].append(formsemestre)
|
|
modimpls = modimpls_by_formsemestre[formsemestre.id]
|
|
if formsemestre.formation.is_apc():
|
|
key = lambda x: x.module.sort_key_apc()
|
|
else:
|
|
key = lambda x: x.module.sort_key()
|
|
modimpls.sort(key=key)
|
|
|
|
return formsemestres_by_dept, modimpls_by_formsemestre
|
|
|
|
def get_evaluations(self) -> list[Evaluation]:
|
|
"Liste de toutes les évaluations du semestre, triées par module/numero"
|
|
return (
|
|
Evaluation.query.join(ModuleImpl)
|
|
.filter_by(formsemestre_id=self.id)
|
|
.join(Module)
|
|
.order_by(
|
|
Module.numero,
|
|
Module.code,
|
|
Evaluation.numero,
|
|
Evaluation.date_debut,
|
|
)
|
|
.all()
|
|
)
|
|
|
|
@cached_property
|
|
def modimpls_sorted(self) -> list[ModuleImpl]:
|
|
"""Liste des modimpls du semestre (y compris bonus)
|
|
- triée par type/numéro/code en APC
|
|
- triée par numéros d'UE/matières/modules pour les formations standard.
|
|
Hors APC, élimine les modules de type ressources et SAEs.
|
|
"""
|
|
modimpls = self.modimpls.all()
|
|
if self.formation.is_apc():
|
|
modimpls.sort(
|
|
key=lambda m: (
|
|
m.module.module_type or 0, # ressources (2) avant SAEs (3)
|
|
m.module.numero or 0,
|
|
m.module.code or 0,
|
|
)
|
|
)
|
|
else:
|
|
modimpls = [
|
|
mi
|
|
for mi in modimpls
|
|
if (
|
|
mi.module.module_type
|
|
not in (scu.ModuleType.RESSOURCE, scu.ModuleType.SAE)
|
|
)
|
|
]
|
|
modimpls.sort(
|
|
key=lambda m: (
|
|
m.module.ue.numero or 0,
|
|
m.module.matiere.numero or 0,
|
|
m.module.numero or 0,
|
|
m.module.code or "",
|
|
)
|
|
)
|
|
return modimpls
|
|
|
|
def modimpls_parcours(self, parcours: ApcParcours) -> list[ModuleImpl]:
|
|
"""Liste des modimpls du semestre (sans les bonus (?)) dans le parcours donné.
|
|
- triée par type/numéro/code ??
|
|
"""
|
|
cursor = db.session.execute(
|
|
text(
|
|
"""
|
|
SELECT modimpl.id
|
|
FROM notes_moduleimpl modimpl, notes_modules mod,
|
|
parcours_modules pm, parcours_formsemestre pf
|
|
WHERE modimpl.formsemestre_id = :formsemestre_id
|
|
AND modimpl.module_id = mod.id
|
|
AND pm.module_id = mod.id
|
|
AND pm.parcours_id = pf.parcours_id
|
|
AND pf.parcours_id = :parcours_id
|
|
AND pf.formsemestre_id = :formsemestre_id
|
|
"""
|
|
),
|
|
{"formsemestre_id": self.id, "parcours_id": parcours.id},
|
|
)
|
|
return [db.session.get(ModuleImpl, modimpl_id) for modimpl_id in cursor]
|
|
|
|
def can_be_edited_by(self, user):
|
|
"""Vrai si user peut modifier ce semestre (est chef ou l'un des responsables)"""
|
|
if not user.has_permission(Permission.EditFormSemestre): # pas chef
|
|
if not self.resp_can_edit or user.id not in [
|
|
resp.id for resp in self.responsables
|
|
]:
|
|
return False
|
|
|
|
return True
|
|
|
|
def est_courant(self) -> bool:
|
|
"""Vrai si la date actuelle (now) est dans le semestre
|
|
(les dates de début et fin sont incluses)
|
|
"""
|
|
today = datetime.date.today()
|
|
return self.date_debut <= today <= self.date_fin
|
|
|
|
def contient_periode(self, date_debut, date_fin) -> bool:
|
|
"""Vrai si l'intervalle [date_debut, date_fin] est
|
|
inclus dans le semestre.
|
|
(les dates de début et fin sont incluses)
|
|
"""
|
|
return (self.date_debut <= date_debut) and (date_fin <= self.date_fin)
|
|
|
|
def est_sur_une_annee(self):
|
|
"""Test si sem est entièrement sur la même année scolaire.
|
|
(ce n'est pas obligatoire mais si ce n'est pas le
|
|
cas les exports Apogée risquent de mal fonctionner)
|
|
Pivot au 1er août par défaut.
|
|
"""
|
|
if self.date_debut > self.date_fin:
|
|
flash(f"Dates début/fin inversées pour le semestre {self.titre_annee()}")
|
|
log(f"Warning: semestre {self.id} begins after ending !")
|
|
annee_debut = self.date_debut.year
|
|
month_debut_annee = ScoDocSiteConfig.get_month_debut_annee_scolaire()
|
|
if self.date_debut.month < month_debut_annee:
|
|
# début sur l'année scolaire précédente (juillet inclus par défaut)
|
|
annee_debut -= 1
|
|
annee_fin = self.date_fin.year
|
|
if self.date_fin.month < (month_debut_annee + 1):
|
|
# 9 (sept) pour autoriser un début en sept et une fin en août
|
|
annee_fin -= 1
|
|
return annee_debut == annee_fin
|
|
|
|
def est_decale(self):
|
|
"""Vrai si semestre "décalé"
|
|
c'est à dire semestres impairs commençant (par défaut)
|
|
entre janvier et juin et les pairs entre juillet et décembre.
|
|
"""
|
|
if self.semestre_id <= 0:
|
|
return False # formations sans semestres
|
|
return (
|
|
# impair
|
|
(
|
|
self.semestre_id % 2
|
|
and self.date_debut.month < scu.MONTH_DEBUT_ANNEE_SCOLAIRE
|
|
)
|
|
or
|
|
# pair
|
|
(
|
|
(not self.semestre_id % 2)
|
|
and self.date_debut.month >= scu.MONTH_DEBUT_ANNEE_SCOLAIRE
|
|
)
|
|
)
|
|
|
|
@classmethod
|
|
def est_in_semestre_scolaire(
|
|
cls,
|
|
date_debut: datetime.date,
|
|
year=False,
|
|
periode=None,
|
|
mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
|
|
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
|
|
) -> bool:
|
|
"""Vrai si la date_debut est dans la période indiquée (1,2,0)
|
|
du semestre `periode` de l'année scolaire indiquée
|
|
(ou, à défaut, de celle en cours).
|
|
|
|
La période utilise les même conventions que semset["sem_id"];
|
|
* 1 : première période
|
|
* 2 : deuxième période
|
|
* 0 ou période non précisée: annualisé (donc inclut toutes les périodes)
|
|
)
|
|
"""
|
|
if not year:
|
|
year = scu.annee_scolaire()
|
|
# n'utilise pas le jour pivot
|
|
jour_pivot_annee = jour_pivot_periode = 1
|
|
# calcule l'année universitaire et la période
|
|
sem_annee, sem_periode = cls.comp_periode(
|
|
date_debut,
|
|
mois_pivot_annee,
|
|
mois_pivot_periode,
|
|
jour_pivot_annee,
|
|
jour_pivot_periode,
|
|
)
|
|
if periode is None or periode == 0:
|
|
return sem_annee == year
|
|
return sem_annee == year and sem_periode == periode
|
|
|
|
def est_terminal(self) -> bool:
|
|
"Vrai si dernier semestre de son cursus (ou formation mono-semestre)"
|
|
return (self.semestre_id < 0) or (
|
|
self.semestre_id == self.formation.get_cursus().NB_SEM
|
|
)
|
|
|
|
@classmethod
|
|
def comp_periode(
|
|
cls,
|
|
date_debut: datetime,
|
|
mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
|
|
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
|
|
jour_pivot_annee=1,
|
|
jour_pivot_periode=1,
|
|
) -> tuple[int, int]:
|
|
"""Calcule la session associée à un formsemestre commençant en date_debut
|
|
sous la forme (année, période)
|
|
année: première année de l'année scolaire
|
|
période = 1 (première période de l'année scolaire, souvent automne)
|
|
ou 2 (deuxième période de l'année scolaire, souvent printemps)
|
|
Les quatre derniers paramètres forment les dates pivots pour l'année
|
|
(1er août par défaut) et pour la période (1er décembre par défaut).
|
|
|
|
Les calculs se font à partir de la date de début indiquée.
|
|
Exemples dans tests/unit/test_periode
|
|
|
|
Implémentation:
|
|
Cas à considérer pour le calcul de la période
|
|
|
|
pa < pp -----------------|-------------------|---------------->
|
|
(A-1, P:2) pa (A, P:1) pp (A, P:2)
|
|
pp < pa -----------------|-------------------|---------------->
|
|
(A-1, P:1) pp (A-1, P:2) pa (A, P:1)
|
|
"""
|
|
pivot_annee = 100 * mois_pivot_annee + jour_pivot_annee
|
|
pivot_periode = 100 * mois_pivot_periode + jour_pivot_periode
|
|
pivot_sem = 100 * date_debut.month + date_debut.day
|
|
if pivot_sem < pivot_annee:
|
|
annee = date_debut.year - 1
|
|
else:
|
|
annee = date_debut.year
|
|
if pivot_annee < pivot_periode:
|
|
if pivot_sem < pivot_annee or pivot_sem >= pivot_periode:
|
|
periode = 2
|
|
else:
|
|
periode = 1
|
|
else:
|
|
if pivot_sem < pivot_periode or pivot_sem >= pivot_annee:
|
|
periode = 1
|
|
else:
|
|
periode = 2
|
|
return annee, periode
|
|
|
|
def periode(self) -> int:
|
|
"""La période:
|
|
* 1 : première période: automne à Paris
|
|
* 2 : deuxième période, printemps à Paris
|
|
"""
|
|
return FormSemestre.comp_periode(
|
|
self.date_debut,
|
|
mois_pivot_annee=ScoDocSiteConfig.get_month_debut_annee_scolaire(),
|
|
mois_pivot_periode=ScoDocSiteConfig.get_month_debut_periode2(),
|
|
)
|
|
|
|
@classmethod
|
|
def get_dept_formsemestres_courants(
|
|
cls, dept: Departement, date_courante: datetime.datetime | None = None
|
|
) -> Query:
|
|
"""Liste (query) ordonnée des formsemestres courants, c'est
|
|
à dire contenant la date courant (si None, la date actuelle)"""
|
|
date_courante = date_courante or db.func.current_date()
|
|
# Les semestres en cours de ce département
|
|
formsemestres = FormSemestre.query.filter(
|
|
FormSemestre.dept_id == dept.id,
|
|
FormSemestre.date_debut <= date_courante,
|
|
FormSemestre.date_fin >= date_courante,
|
|
)
|
|
return formsemestres.order_by(
|
|
FormSemestre.date_debut.desc(),
|
|
FormSemestre.modalite,
|
|
FormSemestre.semestre_id,
|
|
FormSemestre.titre,
|
|
)
|
|
|
|
def etapes_apo_vdi(self) -> list["ApoEtapeVDI"]:
|
|
"Liste des vdis"
|
|
# was read_formsemestre_etapes
|
|
return [e.as_apovdi() for e in self.etapes if e.etape_apo]
|
|
|
|
def etapes_apo_str(self) -> str:
|
|
"""Chaine décrivant les étapes de ce semestre
|
|
ex: "V1RT, V1RT3, V1RT4"
|
|
"""
|
|
if not self.etapes:
|
|
return ""
|
|
return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape]))
|
|
|
|
def add_etape(self, etape_apo: str | ApoEtapeVDI):
|
|
"Ajoute une étape"
|
|
etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=str(etape_apo))
|
|
db.session.add(etape)
|
|
|
|
def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
|
|
"""Calcule la liste des regroupements cohérents d'UE impliquant ce
|
|
formsemestre.
|
|
Pour une année donnée: l'étudiant est inscrit dans ScoDoc soit dans le semestre
|
|
impair, soit pair, soit les deux (il est rare mais pas impossible d'avoir une
|
|
inscription seulement en semestre pair, par exemple suite à un transfert ou un
|
|
arrêt temporaire du cursus).
|
|
|
|
1. Déterminer l'*autre* formsemestre: semestre précédent ou suivant de la même
|
|
année, formation compatible (même référentiel de compétence) dans lequel
|
|
l'étudiant est inscrit.
|
|
|
|
2. Construire les couples d'UE (regroupements cohérents): apparier les UE qui
|
|
ont le même `ApcParcoursNiveauCompetence`.
|
|
"""
|
|
if not self.formation.is_apc():
|
|
return []
|
|
raise NotImplementedError() # XXX
|
|
|
|
def responsables_str(self, abbrev_prenom=True) -> str:
|
|
"""chaîne "J. Dupond, X. Martin"
|
|
ou "Jacques Dupond, Xavier Martin"
|
|
"""
|
|
# was "nomcomplet"
|
|
if not self.responsables:
|
|
return ""
|
|
if abbrev_prenom:
|
|
return ", ".join([u.get_prenomnom() for u in self.responsables])
|
|
else:
|
|
return ", ".join([u.get_nomcomplet() for u in self.responsables])
|
|
|
|
def est_responsable(self, user: User) -> bool:
|
|
"True si l'user est l'un des responsables du semestre"
|
|
return user.id in [u.id for u in self.responsables]
|
|
|
|
def est_chef_or_diretud(self, user: User | None = None) -> bool:
|
|
"Vrai si utilisateur (par def. current) est admin, chef dept ou responsable du semestre"
|
|
user = user or current_user
|
|
return user.has_permission(Permission.EditFormSemestre) or self.est_responsable(
|
|
user
|
|
)
|
|
|
|
def can_change_groups(self, user: User = None) -> bool:
|
|
"""Vrai si l'utilisateur (par def. current) peut changer les groupes dans
|
|
ce semestre: vérifie permission et verrouillage (mais pas si la partition est éditable).
|
|
"""
|
|
if not self.etat:
|
|
return False # semestre verrouillé
|
|
user = user or current_user
|
|
if user.has_permission(Permission.EtudChangeGroups):
|
|
return True # typiquement admin, chef dept
|
|
return self.est_responsable(user)
|
|
|
|
def can_edit_jury(self, user: User | None = None):
|
|
"""Vrai si utilisateur (par def. current) peut saisir decision de jury
|
|
dans ce semestre: vérifie permission et verrouillage.
|
|
"""
|
|
user = user or current_user
|
|
return self.etat and self.est_chef_or_diretud(user)
|
|
|
|
def can_edit_pv(self, user: User = None):
|
|
"Vrai si utilisateur (par def. current) peut editer un PV de jury de ce semestre"
|
|
user = user or current_user
|
|
# Autorise les secrétariats, repérés via la permission EtudChangeAdr
|
|
return self.est_chef_or_diretud(user) or user.has_permission(
|
|
Permission.EtudChangeAdr
|
|
)
|
|
|
|
def annee_scolaire(self) -> int:
|
|
"""L'année de début de l'année scolaire.
|
|
Par exemple, 2022 si le semestre va de septembre 2022 à février 2023."""
|
|
return scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
|
|
|
|
def annee_scolaire_str(self):
|
|
"2021 - 2022"
|
|
return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month)
|
|
|
|
def mois_debut(self) -> str:
|
|
"Oct 2021"
|
|
return f"{MONTH_NAMES_ABBREV[self.date_debut.month - 1]} {self.date_debut.year}"
|
|
|
|
def mois_fin(self) -> str:
|
|
"Jul 2022"
|
|
return f"{MONTH_NAMES_ABBREV[self.date_fin.month - 1]} {self.date_fin.year}"
|
|
|
|
def session_id(self) -> str:
|
|
"""identifiant externe de semestre de formation
|
|
Exemple: RT-DUT-FI-S1-ANNEE
|
|
|
|
DEPT-TYPE-MODALITE+-S?|SPECIALITE
|
|
|
|
TYPE=DUT|LP*|M*
|
|
MODALITE=FC|FI|FA (si plusieurs, en inverse alpha)
|
|
|
|
SPECIALITE=[A-Z]+ EON,ASSUR, ... (si pas Sn ou SnD)
|
|
|
|
ANNEE=annee universitaire de debut (exemple: un S2 de 2013-2014 sera S2-2013)
|
|
"""
|
|
prefs = sco_preferences.SemPreferences(dept_id=self.dept_id)
|
|
imputation_dept = prefs["ImputationDept"]
|
|
if not imputation_dept:
|
|
imputation_dept = prefs["DeptName"]
|
|
imputation_dept = imputation_dept.upper()
|
|
cursus_name = self.formation.get_cursus().NAME
|
|
modalite = self.modalite
|
|
# exception pour code Apprentissage:
|
|
modalite = (modalite or "").replace("FAP", "FA").replace("APP", "FA")
|
|
if self.semestre_id > 0:
|
|
decale = "D" if self.est_decale() else ""
|
|
semestre_id = f"S{self.semestre_id}{decale}"
|
|
else:
|
|
semestre_id = self.formation.code_specialite or ""
|
|
annee_sco = str(
|
|
scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
|
|
)
|
|
return scu.sanitize_string(
|
|
f"{imputation_dept}-{cursus_name}-{modalite}-{semestre_id}-{annee_sco}"
|
|
)
|
|
|
|
def titre_annee(self) -> str:
|
|
"""Le titre avec l'année
|
|
'DUT Réseaux et Télécommunications semestre 3 FAP 2020-2021'
|
|
"""
|
|
titre_annee = (
|
|
f"{self.titre_num()} {self.modalite or ''} {self.date_debut.year}"
|
|
)
|
|
if self.date_fin.year != self.date_debut.year:
|
|
titre_annee += "-" + str(self.date_fin.year)
|
|
return titre_annee
|
|
|
|
def titre_formation(self, with_sem_idx=False):
|
|
"""Titre avec formation, court, pour passerelle: "BUT R&T"
|
|
(méthode de formsemestre car on pourrait ajouter le semestre, ou d'autres infos, à voir)
|
|
"""
|
|
if with_sem_idx and self.semestre_id > 0:
|
|
return f"{self.formation.acronyme} S{self.semestre_id}"
|
|
return self.formation.acronyme
|
|
|
|
def titre_mois(self) -> str:
|
|
"""Le titre et les dates du semestre, pour affichage dans des listes
|
|
Ex: "BUT QLIO (PN 2022) semestre 1 FI (Sept 2022 - Jan 2023)"
|
|
"""
|
|
return f"""{self.titre_num()} {self.modalite or ''} ({
|
|
scu.MONTH_NAMES_ABBREV[self.date_debut.month-1]} {
|
|
self.date_debut.year} - {
|
|
scu.MONTH_NAMES_ABBREV[self.date_fin.month -1]} {
|
|
self.date_fin.year})"""
|
|
|
|
def titre_num(self) -> str:
|
|
"""Le titre et le semestre, ex ""DUT Informatique semestre 2"" """
|
|
if self.semestre_id == codes_cursus.NO_SEMESTRE_ID:
|
|
return self.titre
|
|
return f"{self.titre} {self.formation.get_cursus().SESSION_NAME} {self.semestre_id}"
|
|
|
|
def sem_modalite(self) -> str:
|
|
"""Le semestre et la modalité, ex "S2 FI" ou "S3 APP" """
|
|
if self.semestre_id > 0:
|
|
descr_sem = f"S{self.semestre_id}"
|
|
else:
|
|
descr_sem = ""
|
|
if self.modalite:
|
|
descr_sem += " " + self.modalite
|
|
return descr_sem
|
|
|
|
def get_abs_count(self, etudid) -> tuple[int, int, int]:
|
|
"""Les comptes d'absences de cet étudiant dans ce semestre:
|
|
tuple (nb abs non just, nb abs justifiées, nb abs total)
|
|
Utilise un cache.
|
|
"""
|
|
from app.scodoc import sco_assiduites
|
|
|
|
metrique = sco_preferences.get_preference("assi_metrique", self.id)
|
|
return sco_assiduites.get_assiduites_count_in_interval(
|
|
etudid,
|
|
self.date_debut.isoformat(),
|
|
self.date_fin.isoformat(),
|
|
translate_assiduites_metric(metrique),
|
|
)
|
|
|
|
def get_codes_apogee(self, category=None) -> set[str]:
|
|
"""Les codes Apogée (codés en base comme "VRT1,VRT2")
|
|
category:
|
|
None: tous,
|
|
"etapes": étapes associées,
|
|
"sem: code semestre"
|
|
"annee": code annuel
|
|
"passage": code passage
|
|
"""
|
|
codes = set()
|
|
if category is None or category == "etapes":
|
|
codes |= {e.etape_apo for e in self.etapes if e}
|
|
if (category is None or category == "sem") and self.elt_sem_apo:
|
|
codes |= {x.strip() for x in self.elt_sem_apo.split(",") if x}
|
|
if (category is None or category == "annee") and self.elt_annee_apo:
|
|
codes |= {x.strip() for x in self.elt_annee_apo.split(",") if x}
|
|
if (category is None or category == "passage") and self.elt_passage_apo:
|
|
codes |= {x.strip() for x in self.elt_passage_apo.split(",") if x}
|
|
return codes
|
|
|
|
def get_inscrits(
|
|
self, include_demdef=False, order=False, etats: set | None = None
|
|
) -> list[Identite]:
|
|
"""Liste des étudiants inscrits à ce semestre
|
|
Si include_demdef, tous les étudiants, avec les démissionnaires
|
|
et défaillants.
|
|
Si etats, seuls les étudiants dans l'un des états indiqués.
|
|
Si order, tri par clé sort_key
|
|
"""
|
|
if include_demdef:
|
|
etuds = [ins.etud for ins in self.inscriptions]
|
|
elif not etats:
|
|
etuds = [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT]
|
|
else:
|
|
etuds = [ins.etud for ins in self.inscriptions if ins.etat in etats]
|
|
if order:
|
|
etuds.sort(key=lambda e: e.sort_key)
|
|
return etuds
|
|
|
|
def inscrit_etudiant(
|
|
self,
|
|
etud: "Identite",
|
|
etat: str = scu.INSCRIT,
|
|
etape: str | None = None,
|
|
method: str | None = None,
|
|
) -> "FormSemestreInscription":
|
|
"""Inscrit l'étudiant au semestre, ou renvoie son inscription s'il l'est déjà.
|
|
Vérifie la capacité d'accueil si indiquée (non null): si le semestre est plein,
|
|
lève une exception. Génère un évènement et un log étudiant.
|
|
method: indique origine de l'inscription pour le log étudiant.
|
|
"""
|
|
# remplace ancien do_formsemestre_inscription_create()
|
|
if not self.etat: # check lock
|
|
raise ScoValueError("inscrit_etudiant: semestre verrouille")
|
|
inscr = FormSemestreInscription.query.filter_by(
|
|
formsemestre_id=self.id, etudid=etud.id
|
|
).first()
|
|
if inscr is not None:
|
|
return inscr
|
|
|
|
if self.capacite_accueil is not None:
|
|
# tous sauf démissionnaires:
|
|
inscriptions = self.get_inscrits(etats={scu.INSCRIT, scu.DEF})
|
|
if len(inscriptions) >= self.capacite_accueil:
|
|
raise ScoValueError(
|
|
f"Semestre {self.titre} complet : {len(self.inscriptions)} inscrits",
|
|
dest_url=url_for(
|
|
"notes.formsemestre_status",
|
|
scodoc_dept=g.scodoc_dept,
|
|
formsemestre_id=self.id,
|
|
),
|
|
)
|
|
|
|
inscr = FormSemestreInscription(
|
|
formsemestre_id=self.id, etudid=etud.id, etat=etat, etape=etape
|
|
)
|
|
db.session.add(inscr)
|
|
# Évènement
|
|
event = ScolarEvent(
|
|
etudid=etud.id,
|
|
formsemestre_id=self.id,
|
|
event_type="INSCRIPTION",
|
|
)
|
|
db.session.add(event)
|
|
# Log etudiant
|
|
Scolog.logdb(
|
|
method=method,
|
|
etudid=etud.id,
|
|
msg=f"inscription en semestre {self.titre_annee()}",
|
|
commit=True,
|
|
)
|
|
log(
|
|
f"inscrit_etudiant: {etud.nomprenom} ({etud.id}) au semestre {self.titre_annee()}"
|
|
)
|
|
# Notification mail
|
|
self._notify_inscription(etud)
|
|
sco_cache.invalidate_formsemestre(formsemestre_id=self.id)
|
|
return inscr
|
|
|
|
def desinscrit_etudiant(self, etud: Identite):
|
|
"Désinscrit l'étudiant du semestre (et notifie le cas échéant)"
|
|
inscr_sem = FormSemestreInscription.query.filter_by(
|
|
etudid=etud.id, formsemestre_id=self.id
|
|
).first()
|
|
if not inscr_sem:
|
|
raise ScoValueError(
|
|
f"{etud.nomprenom} ({etud.id}) n'est pas inscrit au semestre !"
|
|
)
|
|
db.session.delete(inscr_sem)
|
|
Scolog.logdb(
|
|
method="desinscrit_etudiant",
|
|
etudid=etud.id,
|
|
msg=f"désinscription semestre {self.titre_annee()}",
|
|
commit=True,
|
|
)
|
|
log(
|
|
f"desinscrit_etudiant: {etud.nomprenom} ({etud.id}) au semestre {self.titre_annee()}"
|
|
)
|
|
self._notify_inscription(etud, action="désinscrit")
|
|
sco_cache.invalidate_formsemestre(formsemestre_id=self.id)
|
|
|
|
def _notify_inscription(self, etud: Identite, action="inscrit") -> None:
|
|
"Notifie inscription d'un étudiant: envoie un mail selon paramétrage"
|
|
destinations = (
|
|
sco_preferences.get_preference("emails_notifications_inscriptions", self.id)
|
|
or ""
|
|
)
|
|
destinations = [x.strip() for x in destinations.split(",")]
|
|
destinations = [x for x in destinations if x]
|
|
if not destinations:
|
|
return
|
|
txt = f"""{etud.nom_prenom()}
|
|
s'est {action}{etud.e}
|
|
en {self.titre_annee()}"""
|
|
subject = f"""Inscription de {etud.nom_prenom()} en {self.titre_annee()}"""
|
|
# build mail
|
|
log(f"_notify_inscription: sending notification to {destinations}")
|
|
log(f"_notify_inscription: subject: {subject}")
|
|
log(txt)
|
|
email.send_email(
|
|
"[ScoDoc] " + subject, email.get_from_addr(), destinations, txt
|
|
)
|
|
|
|
def get_partitions_list(
|
|
self, with_default=True, only_listed=False
|
|
) -> list[Partition]:
|
|
"""Liste des partitions pour ce semestre (list of dicts),
|
|
triées par numéro, avec la partition par défaut en fin de liste.
|
|
Si only_listed, seulement les partitions indiquées "à lister" (show_in_lists).
|
|
"""
|
|
if only_listed:
|
|
partitions = [
|
|
p
|
|
for p in self.partitions
|
|
if p.partition_name is not None and p.show_in_lists
|
|
]
|
|
else:
|
|
partitions = [p for p in self.partitions if p.partition_name is not None]
|
|
if with_default:
|
|
partitions += [p for p in self.partitions if p.partition_name is None]
|
|
return partitions
|
|
|
|
def etudids_actifs(self) -> tuple[list[int], set[int]]:
|
|
"""Liste les etudids inscrits (incluant DEM et DEF),
|
|
qui sera l'index des dataframes de notes
|
|
et donne l'ensemble des inscrits non DEM ni DEF.
|
|
"""
|
|
return [inscr.etudid for inscr in self.inscriptions], {
|
|
ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT
|
|
}
|
|
|
|
@property
|
|
def etuds_inscriptions(self) -> dict:
|
|
"""Map { etudid : inscription } (incluant DEM et DEF)"""
|
|
return {ins.etud.id: ins for ins in self.inscriptions}
|
|
|
|
def setup_parcours_groups(self) -> None:
|
|
"""Vérifie et créee si besoin la partition et les groupes de parcours BUT."""
|
|
if not self.formation.is_apc():
|
|
return
|
|
partition = Partition.query.filter_by(
|
|
formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS
|
|
).first()
|
|
if partition is None:
|
|
# Création de la partition de parcours
|
|
partition = Partition(
|
|
formsemestre_id=self.id,
|
|
partition_name=scu.PARTITION_PARCOURS,
|
|
numero=-1,
|
|
groups_editable=False,
|
|
)
|
|
db.session.add(partition)
|
|
db.session.flush() # pour avoir un id
|
|
flash("Partition Parcours créée.")
|
|
elif partition.groups_editable:
|
|
# Il ne faut jamais laisser éditer cette partition de parcours
|
|
partition.groups_editable = False
|
|
db.session.add(partition)
|
|
|
|
for parcour in self.get_parcours_apc():
|
|
if parcour.code:
|
|
group = GroupDescr.query.filter_by(
|
|
partition_id=partition.id, group_name=parcour.code
|
|
).first()
|
|
if not group:
|
|
partition.groups.append(GroupDescr(group_name=parcour.code))
|
|
db.session.flush()
|
|
# S'il reste des groupes de parcours qui ne sont plus dans le semestre
|
|
# - s'ils n'ont pas d'inscrits, supprime-les.
|
|
# - s'ils ont des inscrits: avertissement
|
|
for group in GroupDescr.query.filter_by(partition_id=partition.id):
|
|
if group.group_name not in (p.code for p in self.get_parcours_apc()):
|
|
if (
|
|
len(
|
|
[
|
|
inscr
|
|
for inscr in self.inscriptions
|
|
if (inscr.parcour is not None)
|
|
and inscr.parcour.code == group.group_name
|
|
]
|
|
)
|
|
== 0
|
|
):
|
|
flash(f"Suppression du groupe de parcours vide {group.group_name}")
|
|
db.session.delete(group)
|
|
else:
|
|
flash(
|
|
f"""Attention: groupe de parcours {group.group_name} non vide:
|
|
réaffectez ses étudiants dans des parcours du semestre"""
|
|
)
|
|
|
|
db.session.commit()
|
|
|
|
def update_inscriptions_parcours_from_groups(self, etudid: int = None) -> None:
|
|
"""Met à jour les inscriptions dans les parcours du semestres en
|
|
fonction des groupes de parcours.
|
|
|
|
Les groupes de parcours sont ceux de la partition scu.PARTITION_PARCOURS
|
|
et leur nom est le code du parcours (eg "Cyber").
|
|
|
|
Si etudid est spécifié, n'affecte que cet étudiant,
|
|
sinon traite tous les inscrits du semestre.
|
|
"""
|
|
if self.formation.referentiel_competence_id is None:
|
|
return # safety net
|
|
partition = Partition.query.filter_by(
|
|
formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS
|
|
).first()
|
|
if partition is None: # pas de partition de parcours
|
|
return
|
|
|
|
# Efface les inscriptions aux parcours:
|
|
if etudid:
|
|
db.session.execute(
|
|
text(
|
|
"""UPDATE notes_formsemestre_inscription
|
|
SET parcour_id=NULL
|
|
WHERE formsemestre_id=:formsemestre_id
|
|
AND etudid=:etudid
|
|
"""
|
|
),
|
|
{
|
|
"formsemestre_id": self.id,
|
|
"etudid": etudid,
|
|
},
|
|
)
|
|
else:
|
|
db.session.execute(
|
|
text(
|
|
"""UPDATE notes_formsemestre_inscription
|
|
SET parcour_id=NULL
|
|
WHERE formsemestre_id=:formsemestre_id
|
|
"""
|
|
),
|
|
{
|
|
"formsemestre_id": self.id,
|
|
},
|
|
)
|
|
# Inscrit les étudiants des groupes de parcours:
|
|
for group in partition.groups:
|
|
query = (
|
|
ApcParcours.query.filter_by(code=group.group_name)
|
|
.join(ApcReferentielCompetences)
|
|
.filter_by(
|
|
dept_id=g.scodoc_dept_id,
|
|
id=self.formation.referentiel_competence_id,
|
|
)
|
|
)
|
|
if query.count() != 1:
|
|
log(
|
|
f"""update_inscriptions_parcours_from_groups: {
|
|
query.count()} parcours with code {group.group_name}"""
|
|
)
|
|
continue
|
|
parcour = query.first()
|
|
if etudid:
|
|
db.session.execute(
|
|
text(
|
|
"""UPDATE notes_formsemestre_inscription ins
|
|
SET parcour_id=:parcour_id
|
|
FROM group_membership gm
|
|
WHERE formsemestre_id=:formsemestre_id
|
|
AND ins.etudid = :etudid
|
|
AND gm.etudid = :etudid
|
|
AND gm.group_id = :group_id
|
|
"""
|
|
),
|
|
{
|
|
"etudid": etudid,
|
|
"formsemestre_id": self.id,
|
|
"parcour_id": parcour.id,
|
|
"group_id": group.id,
|
|
},
|
|
)
|
|
else:
|
|
db.session.execute(
|
|
text(
|
|
"""UPDATE notes_formsemestre_inscription ins
|
|
SET parcour_id=:parcour_id
|
|
FROM group_membership gm
|
|
WHERE formsemestre_id=:formsemestre_id
|
|
AND gm.etudid = ins.etudid
|
|
AND gm.group_id = :group_id
|
|
"""
|
|
),
|
|
{
|
|
"formsemestre_id": self.id,
|
|
"parcour_id": parcour.id,
|
|
"group_id": group.id,
|
|
},
|
|
)
|
|
db.session.commit()
|
|
sco_cache.invalidate_formsemestre(formsemestre_id=self.id)
|
|
|
|
def etud_validations_description_html(self, etudid: int) -> str:
|
|
"""Description textuelle des validations de jury de cet étudiant dans ce semestre"""
|
|
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
|
|
|
|
vals_sem = ScolarFormSemestreValidation.query.filter_by(
|
|
etudid=etudid, formsemestre_id=self.id, ue_id=None
|
|
).all()
|
|
vals_ues = (
|
|
ScolarFormSemestreValidation.query.filter_by(
|
|
etudid=etudid, formsemestre_id=self.id
|
|
)
|
|
.join(UniteEns)
|
|
.order_by(UniteEns.numero, UniteEns.acronyme)
|
|
.all()
|
|
)
|
|
# Validations BUT:
|
|
vals_rcues = (
|
|
ApcValidationRCUE.query.filter_by(etudid=etudid, formsemestre_id=self.id)
|
|
.join(UniteEns, ApcValidationRCUE.ue1)
|
|
.order_by(UniteEns.numero, UniteEns.acronyme)
|
|
.all()
|
|
)
|
|
vals_annee = ( # issues de cette année scolaire seulement
|
|
ApcValidationAnnee.query.filter_by(
|
|
etudid=etudid,
|
|
annee_scolaire=self.annee_scolaire(),
|
|
referentiel_competence_id=self.formation.referentiel_competence_id,
|
|
).all()
|
|
)
|
|
H = []
|
|
for vals in (vals_sem, vals_ues, vals_rcues, vals_annee):
|
|
if vals:
|
|
H.append(
|
|
f"""<ul><li>{"</li><li>".join(str(x) for x in vals)}</li></ul>"""
|
|
)
|
|
return "\n".join(H)
|
|
|
|
def etud_set_all_missing_notes(self, etud: Identite, value=None) -> int:
|
|
"""Met toutes les notes manquantes de cet étudiant dans ce semestre
|
|
(ie dans toutes les évaluations des modules auxquels il est inscrit et n'a pas de note)
|
|
à la valeur donnée par value, qui est en général "ATT", "ABS", "EXC".
|
|
"""
|
|
from app.scodoc import sco_saisie_notes
|
|
|
|
inscriptions = (
|
|
ModuleImplInscription.query.filter_by(etudid=etud.id)
|
|
.join(ModuleImpl)
|
|
.filter_by(formsemestre_id=self.id)
|
|
)
|
|
nb_recorded = 0
|
|
for inscription in inscriptions:
|
|
for evaluation in inscription.modimpl.evaluations:
|
|
if evaluation.get_etud_note(etud) is None:
|
|
if not sco_saisie_notes.do_evaluation_set_etud_note(
|
|
evaluation, etud, value
|
|
):
|
|
raise ScoValueError(
|
|
"erreur lors de l'enregistrement de la note"
|
|
)
|
|
nb_recorded += 1
|
|
return nb_recorded
|
|
|
|
def change_formation(self, formation_dest: Formation):
|
|
"""Associe ce formsemestre à une autre formation.
|
|
Ce n'est possible que si la formation destination possède des modules de
|
|
même code que ceux utilisés dans la formation d'origine du formsemestre.
|
|
S'il manque un module, l'opération est annulée.
|
|
Commit (or rollback) session.
|
|
"""
|
|
ok = True
|
|
for mi in self.modimpls:
|
|
dest_modules = formation_dest.modules.filter_by(code=mi.module.code).all()
|
|
match len(dest_modules):
|
|
case 1:
|
|
mi.module = dest_modules[0]
|
|
db.session.add(mi)
|
|
case 0:
|
|
print(f"Argh ! no module found with code={mi.module.code}")
|
|
ok = False
|
|
case _:
|
|
print(f"Arg ! several modules found with code={mi.module.code}")
|
|
ok = False
|
|
|
|
if ok:
|
|
self.formation_id = formation_dest.id
|
|
db.session.commit()
|
|
else:
|
|
db.session.rollback()
|
|
|
|
|
|
# Association id des utilisateurs responsables (aka directeurs des etudes) du semestre
|
|
notes_formsemestre_responsables = db.Table(
|
|
"notes_formsemestre_responsables",
|
|
db.Column(
|
|
"formsemestre_id",
|
|
db.Integer,
|
|
db.ForeignKey("notes_formsemestre.id"),
|
|
),
|
|
db.Column("responsable_id", db.Integer, db.ForeignKey("user.id")),
|
|
)
|
|
|
|
|
|
class FormSemestreEtape(models.ScoDocModel):
|
|
"""Étape Apogée associée au semestre"""
|
|
|
|
__tablename__ = "notes_formsemestre_etapes"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
formsemestre_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("notes_formsemestre.id"),
|
|
)
|
|
# etape_apo aurait du etre not null, mais oublié
|
|
etape_apo = db.Column(db.String(APO_CODE_STR_LEN), index=True)
|
|
|
|
@classmethod
|
|
def create_from_apovdi(
|
|
cls, formsemestre_id: int, apovdi: ApoEtapeVDI
|
|
) -> "FormSemestreEtape":
|
|
"Crée une instance à partir d'un objet ApoEtapeVDI. Ajoute à la session."
|
|
etape = cls(formsemestre_id=formsemestre_id, etape_apo=str(apovdi))
|
|
db.session.add(etape)
|
|
return etape
|
|
|
|
def __bool__(self):
|
|
"Etape False if code empty"
|
|
return self.etape_apo is not None and (len(self.etape_apo) > 0)
|
|
|
|
def __eq__(self, other):
|
|
if isinstance(other, ApoEtapeVDI):
|
|
return self.as_apovdi() == other
|
|
return str(self) == str(other)
|
|
|
|
def __repr__(self):
|
|
return f"<Etape {self.id} apo={self.etape_apo!r}>"
|
|
|
|
def __str__(self):
|
|
return self.etape_apo or ""
|
|
|
|
def as_apovdi(self) -> "ApoEtapeVDI":
|
|
return ApoEtapeVDI(self.etape_apo)
|
|
|
|
|
|
class FormationModalite(models.ScoDocModel):
|
|
"""Modalités de formation, utilisées pour la présentation
|
|
(grouper les semestres, générer des codes, etc.)
|
|
"""
|
|
|
|
__tablename__ = "notes_form_modalites"
|
|
|
|
DEFAULT_MODALITE = "FI"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
modalite = db.Column(
|
|
db.String(SHORT_STR_LEN),
|
|
unique=True,
|
|
index=True,
|
|
default=DEFAULT_MODALITE,
|
|
server_default=DEFAULT_MODALITE,
|
|
) # code
|
|
titre = db.Column(db.Text()) # texte explicatif
|
|
# numero = ordre de presentation)
|
|
numero = db.Column(db.Integer, nullable=False, default=0)
|
|
|
|
@staticmethod
|
|
def insert_modalites():
|
|
"""Create default modalities"""
|
|
numero = 0
|
|
try:
|
|
for code, titre in (
|
|
(FormationModalite.DEFAULT_MODALITE, "Formation Initiale"),
|
|
("FAP", "Apprentissage"),
|
|
("FC", "Formation Continue"),
|
|
("DEC", "Formation Décalées"),
|
|
("LIC", "Licence"),
|
|
("CPRO", "Contrats de Professionnalisation"),
|
|
("DIST", "À distance"),
|
|
("ETR", "À l'étranger"),
|
|
("EXT", "Extérieur"),
|
|
("OTHER", "Autres formations"),
|
|
):
|
|
modalite = FormationModalite.query.filter_by(modalite=code).first()
|
|
if modalite is None:
|
|
modalite = FormationModalite(
|
|
modalite=code, titre=titre, numero=numero
|
|
)
|
|
db.session.add(modalite)
|
|
numero += 1
|
|
db.session.commit()
|
|
except:
|
|
db.session.rollback()
|
|
raise
|
|
|
|
|
|
class FormSemestreUECoef(models.ScoDocModel):
|
|
"""Coef des UE capitalisees arrivant dans ce semestre"""
|
|
|
|
__tablename__ = "notes_formsemestre_uecoef"
|
|
__table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id"),)
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
formsemestre_uecoef_id = db.synonym("id")
|
|
formsemestre_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("notes_formsemestre.id"),
|
|
index=True,
|
|
)
|
|
ue_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("notes_ue.id"),
|
|
index=True,
|
|
)
|
|
coefficient = db.Column(db.Float, nullable=False)
|
|
|
|
|
|
class FormSemestreUEComputationExpr(models.ScoDocModel):
|
|
"""Formules utilisateurs pour calcul moyenne UE (désactivées en 9.2+)."""
|
|
|
|
__tablename__ = "notes_formsemestre_ue_computation_expr"
|
|
__table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id"),)
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
notes_formsemestre_ue_computation_expr_id = db.synonym("id")
|
|
formsemestre_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("notes_formsemestre.id"),
|
|
)
|
|
ue_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("notes_ue.id"),
|
|
)
|
|
# formule de calcul moyenne
|
|
computation_expr = db.Column(db.Text())
|
|
|
|
|
|
class FormSemestreCustomMenu(models.ScoDocModel):
|
|
"""Menu custom associe au semestre"""
|
|
|
|
__tablename__ = "notes_formsemestre_custommenu"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
custommenu_id = db.synonym("id")
|
|
formsemestre_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("notes_formsemestre.id"),
|
|
)
|
|
title = db.Column(db.Text())
|
|
url = db.Column(db.Text())
|
|
idx = db.Column(db.Integer, default=0, server_default="0") # rang dans le menu
|
|
|
|
|
|
class FormSemestreInscription(models.ScoDocModel):
|
|
"""Inscription à un semestre de formation"""
|
|
|
|
__tablename__ = "notes_formsemestre_inscription"
|
|
__table_args__ = (db.UniqueConstraint("formsemestre_id", "etudid"),)
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
formsemestre_inscription_id = db.synonym("id")
|
|
|
|
etudid = db.Column(
|
|
db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE"), index=True
|
|
)
|
|
formsemestre_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("notes_formsemestre.id"),
|
|
index=True,
|
|
)
|
|
etud = db.relationship(
|
|
Identite,
|
|
backref=db.backref("formsemestre_inscriptions", cascade="all, delete-orphan"),
|
|
)
|
|
formsemestre = db.relationship(
|
|
FormSemestre,
|
|
backref=db.backref(
|
|
"inscriptions",
|
|
cascade="all, delete-orphan",
|
|
order_by="FormSemestreInscription.etudid",
|
|
),
|
|
)
|
|
# I inscrit, D demission en cours de semestre, DEF si "defaillant"
|
|
etat = db.Column(db.String(CODE_STR_LEN), index=True)
|
|
# Etape Apogée d'inscription (ajout 2020)
|
|
etape = db.Column(db.String(APO_CODE_STR_LEN))
|
|
# Parcours (pour les BUT)
|
|
parcour_id = db.Column(
|
|
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="SET NULL"), index=True
|
|
)
|
|
parcour = db.relationship(ApcParcours)
|
|
|
|
def __repr__(self):
|
|
return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={
|
|
self.formsemestre_id} (S{self.formsemestre.semestre_id}) etat={self.etat} {
|
|
('parcours="'+str(self.parcour.code)+'"') if self.parcour else ''
|
|
} {('etape="'+self.etape+'"') if self.etape else ''}>"""
|
|
|
|
|
|
class NotesSemSet(models.ScoDocModel):
|
|
"""semsets: ensemble de formsemestres pour exports Apogée"""
|
|
|
|
__tablename__ = "notes_semset"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
semset_id = db.synonym("id")
|
|
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"))
|
|
|
|
title = db.Column(db.Text)
|
|
annee_scolaire = db.Column(db.Integer, nullable=True, default=None)
|
|
sem_id = db.Column(db.Integer, nullable=False, default=0)
|
|
"période: 0 (année), 1 (Simpair), 2 (Spair)"
|
|
|
|
def set_periode(self, periode: int):
|
|
"""Modifie la période 0 (année), 1 (Simpair), 2 (Spair)"""
|
|
if periode not in {0, 1, 2}:
|
|
raise ValueError("periode invalide")
|
|
self.sem_id = periode
|
|
log(f"semset.set_periode({self.id}, {periode})")
|
|
db.session.add(self)
|
|
db.session.commit()
|
|
|
|
|
|
# Association: many to many
|
|
notes_semset_formsemestre = db.Table(
|
|
"notes_semset_formsemestre",
|
|
db.Column("formsemestre_id", db.Integer, db.ForeignKey("notes_formsemestre.id")),
|
|
db.Column(
|
|
"semset_id",
|
|
db.Integer,
|
|
db.ForeignKey("notes_semset.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
),
|
|
db.UniqueConstraint("formsemestre_id", "semset_id"),
|
|
)
|