ScoDoc/app/models/formsemestre.py

591 lines
21 KiB
Python

# -*- coding: UTF-8 -*
"""ScoDoc models: formsemestre
"""
import datetime
from functools import cached_property
import flask_sqlalchemy
from app import db
from app import log
from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN
from app.models import UniteEns
import app.scodoc.sco_utils as scu
from app.models.ues import UniteEns
from app.models.modules import Module
from app.models.moduleimpls import ModuleImpl
from app.models.etudiants import Identite
from app.scodoc import sco_codes_parcours
from app.scodoc import sco_preferences
from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc.sco_permissions import Permission
class FormSemestre(db.Model):
"""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())
date_debut = db.Column(db.Date())
date_fin = db.Column(db.Date())
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")
) # "FI", "FAP", "FC", ...
# gestion compensation sem DUT:
gestion_compensation = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# ne publie pas le bulletin XML ou JSON:
bul_hide_xml = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# Bloque le calcul des moyennes (générale et d'UE)
block_moyennes = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# semestres decales (pour gestion jurys):
gestion_semestrielle = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# couleur fond bulletins HTML:
bul_bgcolor = db.Column(
db.String(SHORT_STR_LEN), default="white", server_default="white"
)
# autorise resp. a modifier semestre:
resp_can_edit = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# autorise resp. a modifier slt les enseignants:
resp_can_change_ens = db.Column(
db.Boolean(), nullable=False, default=True, server_default="true"
)
# autorise les ens a creer des evals:
ens_can_edit_eval = db.Column(
db.Boolean(), nullable=False, default=False, server_default="False"
)
# code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'
elt_sem_apo = db.Column(db.Text()) # peut être fort long !
# code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'
elt_annee_apo = db.Column(db.Text())
# Relations:
etapes = db.relationship(
"FormSemestreEtape", cascade="all,delete", backref="formsemestre"
)
modimpls = db.relationship(
"ModuleImpl",
backref="formsemestre",
lazy="dynamic",
)
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),
)
# 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)
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_num()}>"
def to_dict(self):
"dict (compatible ScoDoc7)"
d = dict(self.__dict__)
d.pop("_sa_instance_state", 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("%d/%m/%Y")
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("%d/%m/%Y")
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]
return d
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é...
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["titre_num"] = self.titre_num()
d["titreannee"] = self.titre_annee()
d["mois_debut"] = f"{self.date_debut.month} {self.date_debut.year}"
d["mois_fin"] = f"{self.date_fin.month} {self.date_fin.year}"
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()
d["responsables"] = [u.id for u in self.responsables] # liste des ids
return d
def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery:
"""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.
"""
if self.formation.get_parcours().APC_SAE:
sem_ues = UniteEns.query.filter_by(
formation=self.formation, semestre_idx=self.semestre_id
)
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 != sco_codes_parcours.UE_SPORT)
return sem_ues.order_by(UniteEns.numero)
@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.
"""
modimpls = self.modimpls.all()
if self.formation.is_apc():
modimpls.sort(
key=lambda m: (m.module.module_type, m.module.numero, m.module.code)
)
else:
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 can_be_edited_by(self, user):
"""Vrai si user peut modifier ce semestre"""
if not user.has_permission(Permission.ScoImplement): # 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) and (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.
"""
if self.date_debut > self.date_fin:
log(f"Warning: semestre {self.id} begins after ending !")
annee_debut = self.date_debut.year
if self.date_debut.month < 8: # août
# considere que debut sur l'anne scolaire precedente
annee_debut -= 1
annee_fin = self.date_fin.year
if self.date_fin.month < 9:
# 9 (sept) pour autoriser un début en sept et une fin en aout
annee_fin -= 1
return annee_debut == annee_fin
def est_decale(self):
"""Vrai si semestre "décalé"
c'est à dire semestres impairs commençant entre janvier et juin
et les pairs entre juillet et decembre
"""
if self.semestre_id <= 0:
return False # formations sans semestres
return (self.semestre_id % 2 and self.date_debut.month <= 6) or (
not self.semestre_id % 2 and self.date_debut.month > 6
)
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([str(x.etape_apo) for x in self.etapes])
def responsables_str(self, abbrev_prenom=True) -> str:
"""chaîne "J. Dupond, X. Martin"
ou "Jacques Dupond, Xavier Martin"
"""
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 annee_scolaire_str(self):
"2021 - 2022"
return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month)
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)
"""
imputation_dept = sco_preferences.get_preference("ImputationDept", self.id)
if not imputation_dept:
imputation_dept = sco_preferences.get_preference("DeptName")
imputation_dept = imputation_dept.upper()
parcours_name = self.formation.get_parcours().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(
"-".join((imputation_dept, parcours_name, modalite, semestre_id, annee_sco))
)
def titre_annee(self) -> str:
""" """
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_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 == sco_codes_parcours.NO_SEMESTRE_ID:
return self.titre
return f"{self.titre} {self.formation.get_parcours().SESSION_NAME} {self.semestre_id}"
def get_abs_count(self, etudid):
"""Les comptes d'absences de cet étudiant dans ce semestre:
tuple (nb abs non justifiées, nb abs justifiées)
Utilise un cache.
"""
from app.scodoc import sco_abs
return sco_abs.get_abs_count_in_interval(
etudid, self.date_debut.isoformat(), self.date_fin.isoformat()
)
def get_inscrits(self, include_demdef=False, order=False) -> list[Identite]:
"""Liste des étudiants inscrits à ce semestre
Si include_demdef, tous les étudiants, avec les démissionnaires
et défaillants.
Si order, tri par clé sort_key
"""
if include_demdef:
etuds = [ins.etud for ins in self.inscriptions]
else:
etuds = [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT]
if order:
etuds.sort(key=lambda e: e.sort_key)
return etuds
@cached_property
def etudids_actifs(self) -> set:
"Set des etudids inscrits non démissionnaires"
return {ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT}
@cached_property
def etuds_inscriptions(self) -> dict:
"""Map { etudid : inscription } (incluant DEM et DEF)"""
return {ins.etud.id: ins for ins in self.inscriptions}
# 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(db.Model):
"""Étape Apogée associées 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 = db.Column(db.String(APO_CODE_STR_LEN), index=True)
def __repr__(self):
return f"<Etape {self.id} apo={self.etape_apo}>"
def as_apovdi(self):
return ApoEtapeVDI(self.etape_apo)
class FormationModalite(db.Model):
"""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)
@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(db.Model):
"""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(db.Model):
"""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(db.Model):
"""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(db.Model):
"""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"), 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 apogee d'inscription (experimental 2020)
etape = db.Column(db.String(APO_CODE_STR_LEN))
class NotesSemSet(db.Model):
"""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)
# periode: 0 (année), 1 (Simpair), 2 (Spair)
sem_id = db.Column(db.Integer, nullable=True, default=None)
# 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"),
)