forked from ScoDoc/ScoDoc
1143 lines
42 KiB
Python
1143 lines
42 KiB
Python
# -*- coding: UTF-8 -*
|
|
|
|
"""Définition d'un étudiant
|
|
et données rattachées (adresses, annotations, ...)
|
|
"""
|
|
|
|
import datetime
|
|
from functools import cached_property
|
|
from operator import attrgetter
|
|
|
|
from flask import abort, has_request_context, url_for
|
|
from flask import g, request
|
|
import sqlalchemy
|
|
from sqlalchemy import desc, text
|
|
|
|
from app import db, log
|
|
from app import models
|
|
from app.models.departements import Departement
|
|
from app.models.scolar_event import ScolarEvent
|
|
from app.scodoc import notesdb as ndb
|
|
from app.scodoc.sco_bac import Baccalaureat
|
|
from app.scodoc.sco_exceptions import ScoGenError, ScoInvalidParamError, ScoValueError
|
|
import app.scodoc.sco_utils as scu
|
|
|
|
|
|
class Identite(models.ScoDocModel):
|
|
"""étudiant"""
|
|
|
|
__tablename__ = "identite"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
etudid = db.synonym("id")
|
|
|
|
# ForeignKey ondelete set the cascade at the database level
|
|
admission_id = db.Column(
|
|
db.Integer, db.ForeignKey("admissions.id", ondelete="CASCADE"), nullable=True
|
|
)
|
|
admission = db.relationship(
|
|
"Admission",
|
|
back_populates="etud",
|
|
uselist=False,
|
|
cascade="all,delete", # cascade also defined at ORM level
|
|
single_parent=True,
|
|
)
|
|
dept_id = db.Column(
|
|
db.Integer, db.ForeignKey("departement.id"), index=True, nullable=False
|
|
)
|
|
departement = db.relationship("Departement", back_populates="etudiants")
|
|
nom = db.Column(db.Text())
|
|
prenom = db.Column(db.Text())
|
|
nom_usuel = db.Column(db.Text())
|
|
"optionnel (si present, affiché à la place du nom)"
|
|
civilite = db.Column(db.String(1), nullable=False)
|
|
|
|
# données d'état-civil. Si présent (non null) remplace les données d'usage dans les documents
|
|
# officiels (bulletins, PV): voir nomprenom_etat_civil()
|
|
civilite_etat_civil = db.Column(db.String(1), nullable=True)
|
|
prenom_etat_civil = db.Column(db.Text(), nullable=True)
|
|
|
|
date_naissance = db.Column(db.Date)
|
|
lieu_naissance = db.Column(db.Text())
|
|
dept_naissance = db.Column(db.Text())
|
|
nationalite = db.Column(db.Text())
|
|
statut = db.Column(db.Text())
|
|
boursier = db.Column(
|
|
db.Boolean(), nullable=False, default=False, server_default="false"
|
|
)
|
|
"True si boursier"
|
|
photo_filename = db.Column(db.Text())
|
|
# Codes INE et NIP pas unique car le meme etud peut etre ds plusieurs dept
|
|
code_nip = db.Column(db.Text(), index=True)
|
|
code_ine = db.Column(db.Text(), index=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)
|
|
|
|
# ----- Contraintes
|
|
__table_args__ = (
|
|
# Define a unique constraint on (dept_id, code_nip) when code_nip is not NULL
|
|
db.UniqueConstraint("dept_id", "code_nip", name="unique_dept_nip_except_null"),
|
|
db.Index(
|
|
"unique_dept_nip_except_null",
|
|
"dept_id",
|
|
"code_nip",
|
|
unique=True,
|
|
postgresql_where=(code_nip.isnot(None)),
|
|
),
|
|
# Define a unique constraint on (dept_id, code_ine) when code_ine is not NULL
|
|
db.UniqueConstraint("dept_id", "code_ine", name="unique_dept_ine_except_null"),
|
|
db.Index(
|
|
"unique_dept_ine_except_null",
|
|
"dept_id",
|
|
"code_ine",
|
|
unique=True,
|
|
postgresql_where=(code_ine.isnot(None)),
|
|
),
|
|
db.CheckConstraint("civilite IN ('M', 'F', 'X')"), # non nullable
|
|
db.CheckConstraint("civilite_etat_civil IN ('M', 'F', 'X')"), # nullable
|
|
)
|
|
# ----- Relations
|
|
adresses = db.relationship(
|
|
"Adresse", back_populates="etud", cascade="all,delete", lazy="dynamic"
|
|
)
|
|
annotations = db.relationship(
|
|
"EtudAnnotation",
|
|
backref="etudiant",
|
|
cascade="all, delete-orphan",
|
|
lazy="dynamic",
|
|
)
|
|
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
|
|
#
|
|
dispense_ues = db.relationship(
|
|
"DispenseUE",
|
|
back_populates="etud",
|
|
cascade="all, delete",
|
|
passive_deletes=True,
|
|
)
|
|
|
|
# Relations avec les assiduites et les justificatifs
|
|
assiduites = db.relationship(
|
|
"Assiduite", back_populates="etudiant", lazy="dynamic", cascade="all, delete"
|
|
)
|
|
justificatifs = db.relationship(
|
|
"Justificatif", back_populates="etudiant", lazy="dynamic", cascade="all, delete"
|
|
)
|
|
|
|
# Champs "protégés" par ViewEtudData (RGPD)
|
|
protected_attrs = {"boursier", "nationalite"}
|
|
|
|
def __repr__(self):
|
|
return (
|
|
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"
|
|
)
|
|
|
|
def clone(self, not_copying=(), new_dept_id: int = None):
|
|
"""Clone, not copying the given attrs
|
|
Clone aussi les adresses et infos d'admission.
|
|
Si new_dept_id est None, le nouvel étudiant n'a pas de département.
|
|
Attention: la copie n'a pas d'id avant le prochain flush ou commit.
|
|
"""
|
|
if new_dept_id == self.dept_id:
|
|
raise ScoValueError(
|
|
"clonage étudiant: le département destination est identique à celui de départ"
|
|
)
|
|
# Vérifie les contraintes d'unicité
|
|
# ("dept_id", "code_nip") et ("dept_id", "code_ine")
|
|
if (
|
|
self.code_nip is not None
|
|
and Identite.query.filter_by(
|
|
dept_id=new_dept_id, code_nip=self.code_nip
|
|
).count()
|
|
> 0
|
|
) or (
|
|
self.code_ine is not None
|
|
and Identite.query.filter_by(
|
|
dept_id=new_dept_id, code_ine=self.code_ine
|
|
).count()
|
|
> 0
|
|
):
|
|
raise ScoValueError(
|
|
"""clonage étudiant: un étudiant de même code existe déjà
|
|
dans le département destination"""
|
|
)
|
|
d = dict(self.__dict__)
|
|
d.pop("id", None) # get rid of id
|
|
d.pop("_sa_instance_state", None) # get rid of SQLAlchemy special attr
|
|
d.pop("departement", None) # relationship
|
|
d["dept_id"] = new_dept_id
|
|
for k in not_copying:
|
|
d.pop(k, None)
|
|
copy = self.__class__(**d)
|
|
db.session.add(copy)
|
|
copy.adresses = [adr.clone() for adr in self.adresses]
|
|
copy.admission = self.admission.clone()
|
|
log(
|
|
f"cloning etud <{self.id} {self.nom!r} {self.prenom!r}> in dept_id={new_dept_id}"
|
|
)
|
|
return copy
|
|
|
|
def html_link_fiche(self) -> str:
|
|
"lien vers la fiche"
|
|
return (
|
|
f"""<a class="etudlink" href="{self.url_fiche()}">{self.nom_prenom()}</a>"""
|
|
)
|
|
|
|
def url_fiche(self) -> str:
|
|
"url de la fiche étudiant"
|
|
return url_for(
|
|
"scolar.fiche_etud", scodoc_dept=self.departement.acronym, etudid=self.id
|
|
)
|
|
|
|
@classmethod
|
|
def from_request(cls, etudid=None, code_nip=None) -> "Identite":
|
|
"""Étudiant à partir de l'etudid ou du code_nip, soit
|
|
passés en argument soit retrouvés directement dans la requête web.
|
|
Erreur 404 si inexistant.
|
|
"""
|
|
args = make_etud_args(etudid=etudid, code_nip=code_nip)
|
|
return cls.query.filter_by(**args).first_or_404()
|
|
|
|
@classmethod
|
|
def get_etud(cls, etudid: int, accept_none=False) -> "Identite":
|
|
"""Etudiant ou 404 (ou None si accept_none),
|
|
cherche uniquement dans le département courant.
|
|
Si accept_none, return None si l'id est invalide ou ne correspond
|
|
pas à un étudiant.
|
|
"""
|
|
if not isinstance(etudid, int):
|
|
try:
|
|
etudid = int(etudid)
|
|
except (TypeError, ValueError):
|
|
if accept_none:
|
|
return None
|
|
abort(404, "etudid invalide")
|
|
|
|
query = (
|
|
cls.query.filter_by(id=etudid, dept_id=g.scodoc_dept_id)
|
|
if g.scodoc_dept
|
|
else cls.query.filter_by(id=etudid)
|
|
)
|
|
if accept_none:
|
|
return query.first()
|
|
return query.first_or_404()
|
|
|
|
@classmethod
|
|
def create_etud(cls, **args) -> "Identite":
|
|
"""Crée un étudiant, avec admission et adresse vides.
|
|
(added to session but not flushed nor commited)
|
|
"""
|
|
return cls.create_from_dict(args)
|
|
|
|
@classmethod
|
|
def create_from_dict(cls, args) -> "Identite":
|
|
"""Crée un étudiant à partir d'un dict, avec admission et adresse vides.
|
|
If required dept_id or dept are not specified, set it to the current dept.
|
|
args: dict with args in application.
|
|
Les clés adresses et admission ne SONT PAS utilisées.
|
|
(added to session but not flushed nor commited)
|
|
"""
|
|
if not "dept_id" in args:
|
|
if "dept" in args:
|
|
departement = Departement.query.filter_by(acronym=args["dept"]).first()
|
|
if departement:
|
|
args["dept_id"] = departement.id
|
|
if not "dept_id" in args:
|
|
args["dept_id"] = g.scodoc_dept_id
|
|
etud: Identite = super().create_from_dict(args)
|
|
if args.get("admission_id", None) is None:
|
|
etud.admission = Admission()
|
|
etud.adresses.append(Adresse(typeadresse="domicile"))
|
|
db.session.flush()
|
|
|
|
event = ScolarEvent(etud=etud, event_type="CREATION")
|
|
db.session.add(event)
|
|
log(f"Identite.create {etud}")
|
|
return etud
|
|
|
|
def from_dict(self, args, **kwargs) -> bool:
|
|
"""Check arguments, then modify.
|
|
Add to session but don't commit.
|
|
True if modification.
|
|
"""
|
|
check_etud_duplicate_code(args, "code_nip")
|
|
check_etud_duplicate_code(args, "code_ine")
|
|
return super().from_dict(args, **kwargs)
|
|
|
|
@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."""
|
|
return super().filter_model_attributes(
|
|
data,
|
|
excluded=(excluded or set()) | {"adresses", "admission", "departement"},
|
|
)
|
|
|
|
@property
|
|
def civilite_str(self) -> str:
|
|
"""returns civilité usuelle: 'M.' ou 'Mme' ou '' (pour le genre neutre,
|
|
personnes ne souhaitant pas d'affichage).
|
|
"""
|
|
return {"M": "M.", "F": "Mme", "X": ""}[self.civilite]
|
|
|
|
@property
|
|
def civilite_etat_civil_str(self) -> str:
|
|
"""returns 'M.' ou 'Mme', selon état civil officiel.
|
|
La France ne reconnait pas le genre neutre dans l'état civil:
|
|
si cette donnée état civil est précisée, elle est utilisée,
|
|
sinon on renvoie la civilité usuelle.
|
|
"""
|
|
return (
|
|
{"M": "M.", "F": "Mme"}.get(self.civilite_etat_civil, "")
|
|
if self.civilite_etat_civil
|
|
else self.civilite_str
|
|
)
|
|
|
|
def sex_nom(self, no_accents=False) -> str:
|
|
"'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'. Civilité usuelle."
|
|
s = f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()}"
|
|
if no_accents:
|
|
return scu.suppress_accents(s)
|
|
return s
|
|
|
|
@property
|
|
def e(self) -> str:
|
|
"terminaison en français: 'ne', '', 'ou '(e)', selon la civilité usuelle"
|
|
return {"M": "", "F": "e"}.get(self.civilite, "(e)")
|
|
|
|
def nom_disp(self) -> str:
|
|
"""Nom à afficher.
|
|
Note: le nom est stocké en base en majuscules."""
|
|
if self.nom_usuel:
|
|
return (
|
|
(self.nom_usuel + " (" + self.nom + ")") if self.nom else self.nom_usuel
|
|
)
|
|
else:
|
|
return self.nom
|
|
|
|
@property
|
|
def nomprenom(self, reverse=False) -> str:
|
|
"""DEPRECATED: préférer nom_prenom()
|
|
Civilité/prénom/nom pour affichages: "M. Pierre Dupont"
|
|
Si reverse, "Dupont Pierre", sans civilité.
|
|
Prend l'identité courante et non celle de l'état civil si elles diffèrent.
|
|
"""
|
|
nom = self.nom_usuel or self.nom
|
|
prenom = self.prenom_str
|
|
if reverse:
|
|
return f"{nom} {prenom}".strip()
|
|
return f"{self.civilite_str} {prenom} {nom}".strip()
|
|
|
|
def nom_prenom(self) -> str:
|
|
"""Civilite NOM Prénom
|
|
Prend l'identité courante et non celle de l'état civil si elles diffèrent.
|
|
"""
|
|
return f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()} {self.prenom_str}"
|
|
|
|
@property
|
|
def prenom_str(self):
|
|
"""Prénom à afficher. Par exemple: "Jean-Christophe" """
|
|
if not self.prenom:
|
|
return ""
|
|
frags = self.prenom.split()
|
|
r = []
|
|
for frag in frags:
|
|
fields = frag.split("-")
|
|
r.append("-".join([x.lower().capitalize() for x in fields]))
|
|
return " ".join(r)
|
|
|
|
@property
|
|
def etat_civil(self) -> str:
|
|
"M. PRÉNOM NOM, utilisant les données état civil si présentes, usuelles sinon."
|
|
return f"""{self.civilite_etat_civil_str} {
|
|
self.prenom_etat_civil or self.prenom or ''} {
|
|
self.nom or ''}""".strip()
|
|
|
|
@property
|
|
def nom_short(self):
|
|
"Nom et début du prénom pour table recap: 'DUPONT Pi.'"
|
|
return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}."
|
|
|
|
@cached_property
|
|
def sort_key(self) -> str:
|
|
"clé pour tris par ordre alphabétique"
|
|
# Note: scodoc7 utilisait sco_etud.etud_sort_key, à mettre à jour
|
|
# si on modifie cette méthode.
|
|
return scu.sanitize_string(
|
|
(self.nom_usuel or self.nom or "") + ";" + (self.prenom or ""),
|
|
remove_spaces=False,
|
|
).lower()
|
|
|
|
def get_first_email(self, field="email") -> str:
|
|
"Le mail associé à la première adresse de l'étudiant, ou None"
|
|
return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None
|
|
|
|
def get_formsemestres(self, recent_first=True) -> list:
|
|
"""Liste des formsemestres dans lesquels l'étudiant est (a été) inscrit,
|
|
triée par date_debut, le plus récent d'abord (comme "sems" de scodoc7)
|
|
(si recent_first=False, le plus ancien en tête)
|
|
"""
|
|
return sorted(
|
|
[ins.formsemestre for ins in self.formsemestre_inscriptions],
|
|
key=attrgetter("date_debut"),
|
|
reverse=recent_first,
|
|
)
|
|
|
|
def get_modimpls_by_formsemestre(
|
|
self, annee_scolaire: int
|
|
) -> dict[int, list["ModuleImpl"]]:
|
|
"""Pour chaque semestre de l'année indiquée dans lequel l'étudiant
|
|
est inscrit à des moduleimpls, liste ceux ci.
|
|
{ formsemestre_id : [ modimpl, ... ] }
|
|
annee_scolaire est un nombre: eg 2023
|
|
"""
|
|
date_debut_annee = scu.date_debut_annee_scolaire(annee_scolaire)
|
|
date_fin_annee = scu.date_fin_annee_scolaire(annee_scolaire)
|
|
modimpls = (
|
|
ModuleImpl.query.join(ModuleImplInscription)
|
|
.join(FormSemestre)
|
|
.filter(
|
|
(FormSemestre.date_debut <= date_fin_annee)
|
|
& (FormSemestre.date_fin >= date_debut_annee)
|
|
)
|
|
.join(Identite)
|
|
.filter_by(id=self.id)
|
|
)
|
|
# Tri, par semestre puis par module, suivant le type de formation:
|
|
formsemestres = sorted(
|
|
{m.formsemestre for m in modimpls}, key=lambda s: s.sort_key()
|
|
)
|
|
modimpls_by_formsemestre = {}
|
|
for formsemestre in formsemestres:
|
|
modimpls_sem = [m for m in modimpls if m.formsemestre_id == formsemestre.id]
|
|
if formsemestre.formation.is_apc():
|
|
modimpls_sem.sort(key=lambda m: m.module.sort_key_apc())
|
|
else:
|
|
modimpls_sem.sort(
|
|
key=lambda m: (m.module.ue.numero or 0, m.module.numero or 0)
|
|
)
|
|
modimpls_by_formsemestre[formsemestre.id] = modimpls_sem
|
|
return modimpls_by_formsemestre
|
|
|
|
def get_modimpls_from_formsemestre(
|
|
self, formsemestre: "FormSemestre"
|
|
) -> list["ModuleImpl"]:
|
|
"""
|
|
Liste des ModuleImpl auxquels l'étudiant est inscrit dans le formsemestre.
|
|
"""
|
|
modimpls = ModuleImpl.query.join(ModuleImplInscription).filter(
|
|
ModuleImplInscription.etudid == self.id,
|
|
ModuleImpl.formsemestre_id == formsemestre.id,
|
|
)
|
|
return modimpls.all()
|
|
|
|
@classmethod
|
|
def convert_dict_fields(cls, args: dict) -> dict:
|
|
"""Convert fields in the given dict. No other side effect.
|
|
returns: dict to store in model's db.
|
|
"""
|
|
# Les champs qui sont toujours stockés en majuscules:
|
|
fs_uppercase = {"nom", "nom_usuel", "prenom", "prenom_etat_civil"}
|
|
fs_empty_stored_as_nulls = {
|
|
"nom",
|
|
"prenom",
|
|
"nom_usuel",
|
|
"date_naissance",
|
|
"lieu_naissance",
|
|
"dept_naissance",
|
|
"nationalite",
|
|
"statut",
|
|
"photo_filename",
|
|
"code_nip",
|
|
"code_ine",
|
|
}
|
|
args_dict = {}
|
|
for key, value in args.items():
|
|
if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property):
|
|
# compat scodoc7 (mauvaise idée de l'époque)
|
|
if key in fs_empty_stored_as_nulls and value == "":
|
|
value = None
|
|
if key in fs_uppercase and value:
|
|
value = value.upper()
|
|
if key == "civilite": # requis
|
|
value = input_civilite(value)
|
|
elif key == "civilite_etat_civil":
|
|
value = input_civilite_etat_civil(value)
|
|
elif key == "boursier":
|
|
value = scu.to_bool(value)
|
|
elif key == "date_naissance":
|
|
value = ndb.DateDMYtoISO(value)
|
|
args_dict[key] = value
|
|
return args_dict
|
|
|
|
def to_dict_short(self) -> dict:
|
|
"""Les champs essentiels (aucune donnée perso protégée)"""
|
|
return {
|
|
"id": self.id,
|
|
"civilite": self.civilite,
|
|
"code_nip": self.code_nip,
|
|
"code_ine": self.code_ine,
|
|
"dept_id": self.dept_id,
|
|
"nom": self.nom,
|
|
"nom_usuel": self.nom_usuel,
|
|
"prenom": self.prenom,
|
|
"sort_key": self.sort_key,
|
|
"civilite_etat_civil": self.civilite_etat_civil,
|
|
"prenom_etat_civil": self.prenom_etat_civil,
|
|
}
|
|
|
|
def to_dict_scodoc7(self, restrict=False, with_inscriptions=False) -> dict:
|
|
"""Représentation dictionnaire,
|
|
compatible ScoDoc7 mais sans infos admission.
|
|
Si restrict, cache les infos "personnelles" si pas permission ViewEtudData
|
|
Si with_inscriptions, inclut les champs "inscription"
|
|
"""
|
|
e_dict = self.__dict__.copy() # dict(self.__dict__)
|
|
e_dict.pop("_sa_instance_state", None)
|
|
# ScoDoc7 output_formators: (backward compat)
|
|
e_dict["etudid"] = self.id
|
|
e_dict["date_naissance"] = ndb.DateISOtoDMY(e_dict.get("date_naissance", ""))
|
|
e_dict["ne"] = self.e
|
|
e_dict["nomprenom"] = self.nomprenom
|
|
adresse = self.adresses.first()
|
|
if adresse:
|
|
e_dict.update(adresse.to_dict(restrict=restrict))
|
|
if with_inscriptions:
|
|
e_dict.update(self.inscription_descr())
|
|
return {k: v or "" for k, v in e_dict.items()} # convert_null_outputs_to_empty
|
|
|
|
def to_dict_bul(self, include_urls=True):
|
|
"""Infos exportées dans les bulletins
|
|
L'étudiant, et sa première adresse.
|
|
"""
|
|
from app.scodoc import sco_photos
|
|
|
|
d = {
|
|
"boursier": self.boursier or "",
|
|
"civilite_etat_civil": self.civilite_etat_civil,
|
|
"civilite": self.civilite,
|
|
"code_ine": self.code_ine or "",
|
|
"code_nip": self.code_nip or "",
|
|
"date_naissance": (
|
|
self.date_naissance.strftime(scu.DATE_FMT)
|
|
if self.date_naissance
|
|
else ""
|
|
),
|
|
"dept_acronym": self.departement.acronym,
|
|
"dept_id": self.dept_id,
|
|
"dept_naissance": self.dept_naissance or "",
|
|
"email": self.get_first_email() or "",
|
|
"emailperso": self.get_first_email("emailperso"),
|
|
"etat_civil": self.etat_civil,
|
|
"etudid": self.id,
|
|
"lieu_naissance": self.lieu_naissance or "",
|
|
"nationalite": self.nationalite or "",
|
|
"nom": self.nom_disp(),
|
|
"nomprenom": self.nomprenom or "",
|
|
"prenom_etat_civil": self.prenom_etat_civil,
|
|
"prenom": self.prenom or "",
|
|
}
|
|
if include_urls and has_request_context():
|
|
# test request context so we can use this func in tests under the flask shell
|
|
d["fiche_url"] = url_for(
|
|
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=self.id
|
|
)
|
|
d["photo_url"] = sco_photos.get_etud_photo_url(self.id)
|
|
adresse = self.adresses.first()
|
|
if adresse:
|
|
d.update(adresse.to_dict(convert_nulls_to_str=True))
|
|
d["id"] = self.id # a été écrasé par l'id de adresse
|
|
return d
|
|
|
|
def to_dict_api(self, restrict=False, with_annotations=False) -> dict:
|
|
"""Représentation dictionnaire pour export API, avec adresses et admission.
|
|
Si restrict, supprime les infos "personnelles" (boursier)
|
|
"""
|
|
e = dict(self.__dict__)
|
|
e.pop("_sa_instance_state", None)
|
|
admission = self.admission
|
|
e["admission"] = admission.to_dict() if admission is not None else None
|
|
e["adresses"] = [adr.to_dict(restrict=restrict) for adr in self.adresses]
|
|
e["dept_acronym"] = self.departement.acronym
|
|
e.pop("departement", None)
|
|
e["sort_key"] = self.sort_key
|
|
if with_annotations:
|
|
e["annotations"] = (
|
|
[
|
|
annot.to_dict()
|
|
for annot in EtudAnnotation.query.filter_by(
|
|
etudid=self.id
|
|
).order_by(desc(EtudAnnotation.date))
|
|
]
|
|
if not restrict
|
|
else []
|
|
)
|
|
if restrict:
|
|
# Met à None les attributs protégés:
|
|
for attr in self.protected_attrs:
|
|
e[attr] = None
|
|
return e
|
|
|
|
def inscriptions(self) -> list["FormSemestreInscription"]:
|
|
"Liste des inscriptions à des formsemestres, triée, la plus récente en tête"
|
|
return (
|
|
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
|
|
.filter(
|
|
FormSemestreInscription.etudid == self.id,
|
|
)
|
|
.order_by(desc(FormSemestre.date_debut))
|
|
.all()
|
|
)
|
|
|
|
def inscription_courante(self) -> "FormSemestreInscription | None":
|
|
"""La première inscription à un formsemestre _actuellement_ en cours.
|
|
None s'il n'y en a pas (ou plus, ou pas encore).
|
|
"""
|
|
r = [
|
|
ins
|
|
for ins in self.formsemestre_inscriptions
|
|
if ins.formsemestre.est_courant()
|
|
]
|
|
return r[0] if r else None
|
|
|
|
def inscriptions_courantes(self) -> list["FormSemestreInscription"]:
|
|
"""Liste des inscriptions à des semestres _courants_
|
|
(il est rare qu'il y en ai plus d'une, mais c'est possible).
|
|
Triées par date de début de semestre décroissante (le plus récent en premier).
|
|
"""
|
|
return (
|
|
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
|
|
.filter(
|
|
FormSemestreInscription.etudid == self.id,
|
|
text("date_debut < now() and date_fin > now()"),
|
|
)
|
|
.order_by(desc(FormSemestre.date_debut))
|
|
.all()
|
|
)
|
|
|
|
def inscription_courante_date(self, date_debut, date_fin):
|
|
"""La première inscription à un formsemestre incluant la
|
|
période [date_debut, date_fin]
|
|
"""
|
|
r = [
|
|
ins
|
|
for ins in self.formsemestre_inscriptions
|
|
if ins.formsemestre.contient_periode(date_debut, date_fin)
|
|
]
|
|
return r[0] if r else None
|
|
|
|
def inscription_descr(self) -> dict:
|
|
"""Description de l'état d'inscription
|
|
avec champs compatibles templates ScoDoc7
|
|
"""
|
|
inscription_courante = self.inscription_courante()
|
|
if inscription_courante:
|
|
titre_sem = inscription_courante.formsemestre.titre_mois()
|
|
if inscription_courante.etat == scu.DEMISSION:
|
|
inscr_txt = "Démission de"
|
|
elif inscription_courante.etat == scu.DEF:
|
|
inscr_txt = "Défaillant dans"
|
|
else:
|
|
inscr_txt = "Inscrit en"
|
|
|
|
result = {
|
|
"etat_in_cursem": inscription_courante.etat,
|
|
"inscription_courante": inscription_courante,
|
|
"inscription": titre_sem,
|
|
"inscription_str": inscr_txt + " " + titre_sem,
|
|
"situation": self.descr_situation_etud(),
|
|
}
|
|
else:
|
|
if self.formsemestre_inscriptions:
|
|
# cherche l'inscription la plus récente:
|
|
fin_dernier_sem = max(
|
|
[
|
|
inscr.formsemestre.date_debut
|
|
for inscr in self.formsemestre_inscriptions
|
|
]
|
|
)
|
|
if fin_dernier_sem > datetime.date.today():
|
|
inscription = "futur"
|
|
situation = "futur élève"
|
|
else:
|
|
inscription = "ancien"
|
|
situation = "ancien élève"
|
|
else:
|
|
inscription = "non inscrit"
|
|
situation = inscription
|
|
result = {
|
|
"etat_in_cursem": "?",
|
|
"inscription_courante": None,
|
|
"inscription": inscription,
|
|
"inscription_str": inscription,
|
|
"situation": situation,
|
|
}
|
|
# aliases pour compat templates ScoDoc7
|
|
result["etatincursem"] = result["etat_in_cursem"]
|
|
result["inscriptionstr"] = result["inscription_str"]
|
|
|
|
return result
|
|
|
|
def inscription_etat(self, formsemestre_id: int) -> str:
|
|
"""État de l'inscription de cet étudiant au semestre:
|
|
False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF
|
|
"""
|
|
# voir si ce n'est pas trop lent:
|
|
ins = models.FormSemestreInscription.query.filter_by(
|
|
etudid=self.id, formsemestre_id=formsemestre_id
|
|
).first()
|
|
if ins:
|
|
return ins.etat
|
|
return False
|
|
|
|
def descr_situation_etud(self) -> str:
|
|
"""Chaîne décrivant la situation _actuelle_ de l'étudiant.
|
|
Exemple:
|
|
"inscrit en BUT R&T semestre 2 FI (Jan 2022 - Jul 2022) le 16/01/2022"
|
|
ou
|
|
"non inscrit"
|
|
"""
|
|
inscriptions_courantes = self.inscriptions_courantes()
|
|
if inscriptions_courantes:
|
|
inscr = inscriptions_courantes[0]
|
|
if inscr.etat == scu.INSCRIT:
|
|
situation = f"inscrit{self.e} en {inscr.formsemestre.titre_mois()}"
|
|
# Cherche la date d'inscription dans scolar_events:
|
|
events = models.ScolarEvent.query.filter_by(
|
|
etudid=self.id,
|
|
formsemestre_id=inscr.formsemestre.id,
|
|
event_type="INSCRIPTION",
|
|
).all()
|
|
if not events:
|
|
log(
|
|
f"*** situation inconsistante pour {self} (inscrit mais pas d'event)"
|
|
)
|
|
situation += " (inscription non enregistrée)" # ???
|
|
else:
|
|
date_ins = events[0].event_date
|
|
situation += date_ins.strftime(" le %d/%m/%Y")
|
|
elif inscr.etat == scu.DEF:
|
|
situation = f"défaillant en {inscr.formsemestre.titre_mois()}"
|
|
event = (
|
|
models.ScolarEvent.query.filter_by(
|
|
etudid=self.id,
|
|
formsemestre_id=inscr.formsemestre.id,
|
|
event_type="DEFAILLANCE",
|
|
)
|
|
.order_by(models.ScolarEvent.event_date)
|
|
.first()
|
|
)
|
|
if not event:
|
|
log(
|
|
f"*** situation inconsistante pour {self} (def mais pas d'event)"
|
|
)
|
|
situation += "???" # ???
|
|
else:
|
|
date_def = event.event_date
|
|
situation += date_def.strftime(" le %d/%m/%Y")
|
|
|
|
else:
|
|
situation = f"démission de {inscr.formsemestre.titre_mois()}"
|
|
# Cherche la date de demission dans scolar_events:
|
|
event = (
|
|
models.ScolarEvent.query.filter_by(
|
|
etudid=self.id,
|
|
formsemestre_id=inscr.formsemestre.id,
|
|
event_type="DEMISSION",
|
|
)
|
|
.order_by(models.ScolarEvent.event_date)
|
|
.first()
|
|
)
|
|
if not event:
|
|
log(
|
|
f"*** situation inconsistante pour {self} (demission mais pas d'event)"
|
|
)
|
|
situation += "???" # ???
|
|
else:
|
|
date_dem = event.event_date
|
|
situation += date_dem.strftime(" le %d/%m/%Y")
|
|
else:
|
|
situation = "non inscrit" + self.e
|
|
|
|
return situation
|
|
|
|
def etat_civil_pv(self, with_paragraph=True, line_sep="\n") -> str:
|
|
"""Présentation, pour PV jury
|
|
Si with_paragraph (défaut):
|
|
M. Pierre Dupont
|
|
n° 12345678
|
|
né(e) le 7/06/1974
|
|
à Paris
|
|
Sinon:
|
|
M. Pierre Dupont
|
|
"""
|
|
if with_paragraph:
|
|
return f"""{self.etat_civil}{line_sep}n°{self.code_nip or ""}{line_sep}né{self.e} le {
|
|
self.date_naissance.strftime(scu.DATE_FMT) if self.date_naissance else ""}{
|
|
line_sep}à {self.lieu_naissance or ""}"""
|
|
return self.etat_civil
|
|
|
|
def photo_html(self, title=None, size="small") -> str:
|
|
"""HTML img tag for the photo, either in small size (h90)
|
|
or original size (size=="orig")
|
|
"""
|
|
from app.scodoc import sco_photos
|
|
|
|
# sco_photo traite des dicts:
|
|
return sco_photos.etud_photo_html(
|
|
etud=dict(
|
|
etudid=self.id,
|
|
code_nip=self.code_nip,
|
|
nomprenom=self.nomprenom,
|
|
nom_disp=self.nom_disp(),
|
|
photo_filename=self.photo_filename,
|
|
),
|
|
title=title,
|
|
size=size,
|
|
)
|
|
|
|
|
|
def check_etud_duplicate_code(args, code_name, edit=True):
|
|
"""Vérifie que le code n'est pas dupliqué.
|
|
Raises ScoGenError si problème.
|
|
"""
|
|
etudid = args.get("etudid", None)
|
|
if not args.get(code_name, None):
|
|
return
|
|
etuds = Identite.query.filter_by(
|
|
**{code_name: str(args[code_name]), "dept_id": g.scodoc_dept_id}
|
|
).all()
|
|
duplicate = False
|
|
if edit:
|
|
duplicate = (len(etuds) > 1) or ((len(etuds) == 1) and etuds[0].id != etudid)
|
|
else:
|
|
duplicate = len(etuds) > 0
|
|
if duplicate:
|
|
listh = [] # liste des doubles
|
|
for etud in etuds:
|
|
listh.append(f"Autre étudiant: {etud.html_link_fiche()}")
|
|
if etudid:
|
|
submit_label = "retour à la fiche étudiant"
|
|
dest_endpoint = "scolar.fiche_etud"
|
|
parameters = {"etudid": etudid}
|
|
else:
|
|
if "tf_submitted" in args:
|
|
del args["tf_submitted"]
|
|
submit_label = "Continuer"
|
|
dest_endpoint = "scolar.etudident_create_form"
|
|
parameters = args
|
|
else:
|
|
submit_label = "Annuler"
|
|
dest_endpoint = "notes.index_html"
|
|
parameters = {}
|
|
|
|
err_page = f"""<h3>Code étudiant ({code_name}) dupliqué !</h3>
|
|
<p class="help">Le {code_name} {args[code_name]} est déjà utilisé: un seul étudiant peut avoir
|
|
ce code. Vérifier votre valeur ou supprimer l'autre étudiant avec cette valeur.
|
|
</p>
|
|
<ul><li>
|
|
{ '</li><li>'.join(listh) }
|
|
</li></ul>
|
|
<p>
|
|
<a href="{ url_for(dest_endpoint, scodoc_dept=g.scodoc_dept, **parameters) }
|
|
">{submit_label}</a>
|
|
</p>
|
|
"""
|
|
|
|
log(f"*** error: code {code_name} duplique: {args[code_name]}")
|
|
|
|
raise ScoGenError(err_page, safe=True)
|
|
|
|
|
|
def make_etud_args(
|
|
etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True
|
|
) -> dict:
|
|
"""forme args dict pour requete recherche etudiant
|
|
On peut specifier etudid
|
|
ou bien (si use_request) cherche dans la requete http: etudid, code_nip, code_ine
|
|
(dans cet ordre).
|
|
|
|
Résultat: dict avec soit "etudid", soit "code_nip", soit "code_ine"
|
|
"""
|
|
args = None
|
|
if etudid:
|
|
try:
|
|
args = {"etudid": int(etudid)}
|
|
except ValueError as exc:
|
|
raise ScoInvalidParamError() from exc
|
|
elif code_nip:
|
|
args = {"code_nip": code_nip}
|
|
elif use_request: # use form from current request (Flask global)
|
|
if request.method == "POST":
|
|
vals = request.form
|
|
elif request.method == "GET":
|
|
vals = request.args
|
|
else:
|
|
vals = {}
|
|
try:
|
|
if "etudid" in vals:
|
|
args = {"etudid": int(vals["etudid"])}
|
|
elif "code_nip" in vals:
|
|
args = {"code_nip": str(vals["code_nip"])}
|
|
elif "code_ine" in vals:
|
|
args = {"code_ine": str(vals["code_ine"])}
|
|
except ValueError:
|
|
args = {}
|
|
if not args:
|
|
if abort_404:
|
|
abort(404, "pas d'étudiant sélectionné")
|
|
elif raise_exc:
|
|
raise ValueError("make_etud_args: pas d'étudiant sélectionné !")
|
|
return args
|
|
|
|
|
|
def input_civilite(s: str) -> str:
|
|
"""Converts external representation of civilite to internal:
|
|
'M', 'F', or 'X' (and nothing else).
|
|
Raises ScoValueError if conversion fails.
|
|
"""
|
|
if not isinstance(s, str):
|
|
raise ScoValueError("valeur invalide pour la civilité (chaine attendue)")
|
|
s = s.upper().strip()
|
|
if s in ("M", "M.", "MR", "H"):
|
|
return "M"
|
|
elif s in ("F", "MLLE", "MLLE.", "MELLE", "MME"):
|
|
return "F"
|
|
elif s == "X" or not s:
|
|
return "X"
|
|
raise ScoValueError(f"valeur invalide pour la civilité: {s}")
|
|
|
|
|
|
def input_civilite_etat_civil(s: str) -> str | None:
|
|
"""Same as input_civilite, but empty gives None (null)"""
|
|
return input_civilite(s) if s and s.strip() else None
|
|
|
|
|
|
PIVOT_YEAR = 70
|
|
|
|
|
|
def pivot_year(y) -> int:
|
|
"converti et calcule l'année si saisie à deux chiffres"
|
|
if y == "" or y is None:
|
|
return None
|
|
y = int(round(float(y)))
|
|
if y >= 0 and y < 100:
|
|
if y < PIVOT_YEAR:
|
|
y = y + 2000
|
|
else:
|
|
y = y + 1900
|
|
return y
|
|
|
|
|
|
class Adresse(models.ScoDocModel):
|
|
"""Adresse d'un étudiant
|
|
(le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule)
|
|
"""
|
|
|
|
__tablename__ = "adresse"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
adresse_id = db.synonym("id")
|
|
etudid = db.Column(db.Integer, db.ForeignKey("identite.id"), nullable=False)
|
|
# Relationship to Identite
|
|
etud = db.relationship("Identite", back_populates="adresses")
|
|
|
|
email = db.Column(db.Text()) # mail institutionnel
|
|
emailperso = db.Column(db.Text) # email personnel (exterieur)
|
|
domicile = db.Column(db.Text)
|
|
codepostaldomicile = db.Column(db.Text)
|
|
villedomicile = db.Column(db.Text)
|
|
paysdomicile = db.Column(db.Text)
|
|
telephone = db.Column(db.Text)
|
|
telephonemobile = db.Column(db.Text)
|
|
fax = db.Column(db.Text)
|
|
typeadresse = db.Column(
|
|
db.Text, default="domicile", server_default="domicile", nullable=False
|
|
)
|
|
description = db.Column(db.Text)
|
|
|
|
# Champs "protégés" par ViewEtudData (RGPD)
|
|
protected_attrs = {
|
|
"emailperso",
|
|
"domicile",
|
|
"codepostaldomicile",
|
|
"villedomicile",
|
|
"telephone",
|
|
"telephonemobile",
|
|
"fax",
|
|
}
|
|
|
|
def to_dict(self, convert_nulls_to_str=False, restrict=False):
|
|
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
|
|
e = dict(self.__dict__)
|
|
e.pop("_sa_instance_state", None)
|
|
if convert_nulls_to_str:
|
|
e = {k: v or "" for k, v in e.items()}
|
|
if restrict:
|
|
e = {k: v for (k, v) in e.items() if k not in self.protected_attrs}
|
|
return e
|
|
|
|
|
|
class Admission(models.ScoDocModel):
|
|
"""Informations liées à l'admission d'un étudiant"""
|
|
|
|
__tablename__ = "admissions"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
adm_id = db.synonym("id")
|
|
# obsoleted by migration 497ba81343f7_identite_admission.py:
|
|
# etudid = db.Column(
|
|
# db.Integer,
|
|
# db.ForeignKey("identite.id", ondelete="CASCADE"),
|
|
# )
|
|
etud = db.relationship("Identite", back_populates="admission", uselist=False)
|
|
|
|
# Anciens champs de ScoDoc7, à revoir pour être plus générique et souple
|
|
# notamment dans le cadre du bac 2021
|
|
# de plus, certaines informations liées à APB ne sont plus disponibles
|
|
# avec Parcoursup
|
|
annee = db.Column(db.Integer)
|
|
bac = db.Column(db.Text)
|
|
specialite = db.Column(db.Text)
|
|
annee_bac = db.Column(db.Integer)
|
|
math = db.Column(db.Text)
|
|
physique = db.Column(db.Text)
|
|
anglais = db.Column(db.Text)
|
|
francais = db.Column(db.Text)
|
|
# Rang dans les voeux du candidat (inconnu avec APB et PS)
|
|
rang = db.Column(db.Integer)
|
|
# Qualité et décision du jury d'admission (ou de l'examinateur)
|
|
qualite = db.Column(db.Float)
|
|
rapporteur = db.Column(db.Text)
|
|
decision = db.Column(db.Text)
|
|
score = db.Column(db.Float)
|
|
commentaire = db.Column(db.Text)
|
|
# Lycée d'origine:
|
|
nomlycee = db.Column(db.Text)
|
|
villelycee = db.Column(db.Text)
|
|
codepostallycee = db.Column(db.Text)
|
|
codelycee = db.Column(db.Text)
|
|
# 'APB', 'APC-PC', 'CEF', 'Direct', '?' (autre)
|
|
type_admission = db.Column(db.Text)
|
|
# était boursier dans le cycle precedent (lycee) ?
|
|
boursier_prec = db.Column(db.Boolean())
|
|
# classement par le jury d'admission (1 à N),
|
|
# global (pas celui d'APB si il y a des groupes)
|
|
classement = db.Column(db.Integer)
|
|
# code du groupe APB
|
|
apb_groupe = db.Column(db.Text)
|
|
# classement (1..Ngr) par le jury dans le groupe APB
|
|
apb_classement_gr = db.Column(db.Integer)
|
|
|
|
# Tous les champs sont "protégés" par ViewEtudData (RGPD)
|
|
# sauf:
|
|
not_protected_attrs = {"bac", "specialite", "anne_bac"}
|
|
|
|
def get_bac(self) -> Baccalaureat:
|
|
"Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères."
|
|
return Baccalaureat(self.bac, specialite=self.specialite)
|
|
|
|
def to_dict(self, no_nulls=False, restrict=False):
|
|
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
|
|
d = dict(self.__dict__)
|
|
d.pop("_sa_instance_state", None)
|
|
if no_nulls:
|
|
for key, value in d.items():
|
|
if value is None:
|
|
col_type = getattr(
|
|
sqlalchemy.inspect(models.Admission).columns, key
|
|
).expression.type
|
|
if isinstance(col_type, sqlalchemy.Text):
|
|
d[key] = ""
|
|
elif isinstance(col_type, sqlalchemy.Integer):
|
|
d[key] = 0
|
|
elif isinstance(col_type, sqlalchemy.Boolean):
|
|
d[key] = False
|
|
if restrict:
|
|
d = {k: v for (k, v) in d.items() if k in self.not_protected_attrs}
|
|
return d
|
|
|
|
@classmethod
|
|
def convert_dict_fields(cls, args: dict) -> dict:
|
|
"""Convert fields in the given dict. No other side effect.
|
|
args: dict with args in application.
|
|
returns: dict to store in model's db.
|
|
"""
|
|
fs_uppercase = {"bac", "specialite"}
|
|
args_dict = {}
|
|
for key, value in args.items():
|
|
if hasattr(cls, key):
|
|
if (
|
|
value == ""
|
|
): # les chaines vides donne des NULLS (scodoc7 convention)
|
|
value = None
|
|
if key in fs_uppercase and value:
|
|
value = value.upper()
|
|
elif key == "annee" or key == "annee_bac":
|
|
value = pivot_year(value)
|
|
elif key == "classement" or key == "apb_classement_gr":
|
|
value = ndb.int_null_is_null(value)
|
|
args_dict[key] = value
|
|
return args_dict
|
|
|
|
|
|
class ItemSuivi(models.ScoDocModel):
|
|
"""Suivi scolarité / débouchés"""
|
|
|
|
__tablename__ = "itemsuivi"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
itemsuivi_id = db.synonym("id")
|
|
etudid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("identite.id", ondelete="CASCADE"),
|
|
)
|
|
item_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
|
|
situation = db.Column(db.Text)
|
|
|
|
_sco_dept_relations = ("Identite",) # accès au dept_id
|
|
|
|
|
|
class ItemSuiviTag(db.Model):
|
|
__tablename__ = "itemsuivi_tags"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
|
|
tag_id = db.synonym("id")
|
|
title = db.Column(db.Text(), nullable=False, unique=True)
|
|
|
|
|
|
# Association tag <-> module
|
|
itemsuivi_tags_assoc = db.Table(
|
|
"itemsuivi_tags_assoc",
|
|
db.Column(
|
|
"tag_id", db.Integer, db.ForeignKey("itemsuivi_tags.id", ondelete="CASCADE")
|
|
),
|
|
db.Column(
|
|
"itemsuivi_id", db.Integer, db.ForeignKey("itemsuivi.id", ondelete="CASCADE")
|
|
),
|
|
)
|
|
|
|
|
|
class EtudAnnotation(models.ScoDocModel):
|
|
"""Annotation sur un étudiant"""
|
|
|
|
__tablename__ = "etud_annotations"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
|
|
etudid = db.Column(db.Integer, db.ForeignKey(Identite.id))
|
|
author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user
|
|
comment = db.Column(db.Text)
|
|
confidential = db.Column(db.Boolean, default=False)
|
|
|
|
_sco_dept_relations = ("Identite",) # accès au dept_id
|
|
|
|
def to_dict(self):
|
|
"""Représentation dictionnaire."""
|
|
e = dict(self.__dict__)
|
|
e.pop("_sa_instance_state", None)
|
|
return e
|
|
|
|
|
|
from app.models.formsemestre import FormSemestre, FormSemestreInscription
|
|
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
|