ScoDoc/app/models/etudiants.py

578 lines
20 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 flask import abort, 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.scodoc import notesdb as ndb
from app.scodoc.sco_bac import Baccalaureat
from app.scodoc.sco_exceptions import ScoInvalidParamError
import app.scodoc.sco_utils as scu
class Identite(db.Model):
"""étudiant"""
__tablename__ = "identite"
__table_args__ = (
db.UniqueConstraint("dept_id", "code_nip"),
db.UniqueConstraint("dept_id", "code_ine"),
)
id = db.Column(db.Integer, primary_key=True)
etudid = db.synonym("id")
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
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)
__table_args__ = (db.CheckConstraint("civilite IN ('M', 'F', 'X')"),)
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()) # True si boursier ('O' en ScoDoc7)
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)
#
adresses = db.relationship("Adresse", lazy="dynamic", backref="etud")
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
# one-to-one relation:
admission = db.relationship("Admission", backref="identite", lazy="dynamic")
def __repr__(self):
return f"<Etud {self.id} {self.nom} {self.prenom}>"
@classmethod
def from_request(cls, etudid=None, code_nip=None):
"""Etudiant à 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 Identite.query.filter_by(**args).first_or_404()
@property
def civilite_str(self):
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre,
personnes ne souhaitant pas d'affichage).
"""
return {"M": "M.", "F": "Mme", "X": ""}[self.civilite]
def sex_nom(self, no_accents=False) -> str:
"'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'"
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):
"terminaison en français: 'ne', '', 'ou '(e)'"
return {"M": "", "F": "e"}.get(self.civilite, "(e)")
def nom_disp(self) -> str:
"Nom à afficher"
if self.nom_usuel:
return (
(self.nom_usuel + " (" + self.nom + ")") if self.nom else self.nom_usuel
)
else:
return self.nom
@cached_property
def nomprenom(self, reverse=False) -> str:
"""Civilité/nom/prenom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité.
"""
nom = self.nom_usuel or self.nom
prenom = self.prenom_str
if reverse:
fields = (nom, prenom)
else:
fields = (self.civilite_str, prenom, nom)
return " ".join([x for x in fields if x])
@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 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) -> tuple:
"clé pour tris par ordre alphabétique"
return (
scu.suppress_accents(self.nom_usuel or self.nom or "").lower(),
scu.suppress_accents(self.prenom or "").lower(),
)
def get_first_email(self, field="email") -> str:
"Le mail associé à la première adrese de l'étudiant, ou None"
return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None
def to_dict_short(self) -> dict:
"""Les champs essentiels"""
return {
"id": self.id,
"nip": self.code_nip,
"ine": self.code_ine,
"nom": self.nom,
"nom_usuel": self.nom_usuel,
"prenom": self.prenom,
"civilite": self.civilite,
}
def to_dict_scodoc7(self) -> dict:
"""Représentation dictionnaire,
compatible ScoDoc7 mais sans infos admission
"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
# ScoDoc7 output_formators: (backward compat)
e["etudid"] = self.id
e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"])
e["ne"] = self.e
return {k: e[k] or "" for k in e} # 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
dept = models.Departement.query.filter_by(id=self.dept_id).first_or_404()
d = {
"civilite": self.civilite,
"code_ine": self.code_ine or "",
"code_nip": self.code_nip or "",
"date_naissance": self.date_naissance.strftime("%d/%m/%Y")
if self.date_naissance
else "",
"dept_id": self.dept_id,
"dept_acronym": dept.acronym,
"email": self.get_first_email() or "",
"emailperso": self.get_first_email("emailperso"),
"etudid": self.id,
"nom": self.nom_disp(),
"prenom": self.prenom or "",
"nomprenom": self.nomprenom or "",
"lieu_naissance": self.lieu_naissance or "",
"dept_naissance": self.dept_naissance or "",
"nationalite": self.nationalite or "",
"boursier": self.boursier or "",
}
if include_urls:
d["fiche_url"] = url_for(
"scolar.ficheEtud", 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))
return d
def inscription_courante(self):
"""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: # -> 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).
"""
from app.models.formsemestre import FormSemestre, FormSemestreInscription
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_etat(self, formsemestre_id):
"""É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 inscription_descr(self) -> dict:
"""Description de l'état d'inscription"""
inscription_courante = self.inscription_courante()
if inscription_courante:
titre_sem = inscription_courante.formsemestre.titre_mois()
return {
"etat_in_cursem": inscription_courante.etat,
"inscription_courante": inscription_courante,
"inscription": titre_sem,
"inscription_str": "Inscrit en " + 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
return {
"etat_in_cursem": "?",
"inscription_courante": None,
"inscription": inscription,
"inscription_str": inscription,
"situation": situation,
}
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 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 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
class Adresse(db.Model):
"""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"),
)
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)
def to_dict(self, convert_nulls_to_str=False):
"""Représentation dictionnaire,"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
if convert_nulls_to_str:
return {k: e[k] or "" for k in e}
return e
class Admission(db.Model):
"""Informations liées à l'admission d'un étudiant"""
__tablename__ = "admissions"
id = db.Column(db.Integer, primary_key=True)
adm_id = db.synonym("id")
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id"),
)
# 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.Float)
anglais = db.Column(db.Float)
francais = db.Column(db.Float)
# 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)
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):
"""Représentation dictionnaire,"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
if no_nulls:
for k in e:
if e[k] is None:
col_type = getattr(
sqlalchemy.inspect(models.Admission).columns, "apb_groupe"
).expression.type
if isinstance(col_type, sqlalchemy.Text):
e[k] = ""
elif isinstance(col_type, sqlalchemy.Integer):
e[k] = 0
elif isinstance(col_type, sqlalchemy.Boolean):
e[k] = False
return e
# Suivi scolarité / débouchés
class ItemSuivi(db.Model):
__tablename__ = "itemsuivi"
id = db.Column(db.Integer, primary_key=True)
itemsuivi_id = db.synonym("id")
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id"),
)
item_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
situation = db.Column(db.Text)
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(db.Model):
"""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) # sans contrainte (compat ScoDoc 7))
author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user
comment = db.Column(db.Text)