# -*- 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.scodoc import notesdb as ndb from app.scodoc.sco_bac import Baccalaureat from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError 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"), db.CheckConstraint("civilite IN ('M', 'F', 'X')"), db.CheckConstraint("civilite_etat_civil IN ('M', 'F', 'X')"), ) 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) # données d'état-civil. Si présent 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=False, server_default="X") prenom_etat_civil = db.Column(db.Text(), nullable=False, server_default="") 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") # admission = db.relationship("Admission", backref="identite", lazy="dynamic") dispense_ues = db.relationship( "DispenseUE", back_populates="etud", cascade="all, delete", passive_deletes=True, ) def __repr__(self): return ( f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>" ) def html_link_fiche(self) -> str: "lien vers la fiche" return f"""<a class="stdlink" href="{ url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id) }">{self.nomprenom}</a>""" @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) -> "Identite": """Etudiant ou 404, cherche uniquement dans le département courant""" if g.scodoc_dept: return cls.query.filter_by( id=etudid, dept_id=g.scodoc_dept_id ).first_or_404() return cls.query.filter_by(id=etudid).first_or_404() @classmethod def create_etud(cls, **args): "Crée un étudiant, avec admission et adresse vides." etud: Identite = cls(**args) etud.adresses.append(Adresse(typeadresse="domicile")) etud.admission.append(Admission()) return etud @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] @property def civilite_etat_civil_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_etat_civil] 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 etat_civil(self): if self.prenom_etat_civil: civ = {"M": "M.", "F": "Mme", "X": ""}[self.civilite_etat_civil] return f"{civ} {self.prenom_etat_civil} {self.nom}" else: return self.nomprenom @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.sanitize_string( self.nom_usuel or self.nom or "", remove_spaces=False ).lower(), scu.sanitize_string(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) -> list: """Liste des formsemestres dans lesquels l'étudiant est (a été) inscrit, triée par date_debut """ return sorted( [ins.formsemestre for ins in self.formsemestre_inscriptions], key=attrgetter("date_debut"), reverse=True, ) @classmethod def convert_dict_fields(cls, args: dict) -> dict: "Convert fields in the given dict. No other side effect" fs_uppercase = {"nom", "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" or key == "civilite_etat_civil": value = input_civilite(value) elif key == "boursier": value = bool(value) elif key == "date_naissance": value = ndb.DateDMYtoISO(value) args_dict[key] = value return args_dict def from_dict(self, args: dict): "update fields given in dict. Add to session but don't commit." args_dict = Identite.convert_dict_fields(args) args_dict.pop("id", None) args_dict.pop("etudid", None) for key, value in args_dict.items(): if hasattr(self, key): setattr(self, key, value) db.session.add(self) def to_dict_short(self) -> dict: """Les champs essentiels""" 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) -> 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 e["nomprenom"] = self.nomprenom adresse = self.adresses.first() if adresse: e.update(adresse.to_dict()) 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 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": self.departement.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 "", "civilite_etat_civil": self.civilite_etat_civil, "prenom_etat_civil": self.prenom_etat_civil, } 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.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)) d["id"] = self.id # a été écrasé par l'id de adresse return d def to_dict_api(self) -> dict: """Représentation dictionnaire pour export API, avec adresses et admission.""" e = dict(self.__dict__) e.pop("_sa_instance_state", None) admission = self.admission.first() e["admission"] = admission.to_dict() if admission is not None else None e["adresses"] = [adr.to_dict() for adr in self.adresses] e["dept_acronym"] = self.departement.acronym e.pop("departement", None) e["sort_key"] = self.sort_key return e def inscriptions(self) -> list["FormSemestreInscription"]: "Liste des inscriptions à des formsemestres, triée, la plus récente en tête" from app.models.formsemestre import FormSemestre, FormSemestreInscription return ( FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) .filter( FormSemestreInscription.etudid == self.id, ) .order_by(desc(FormSemestre.date_debut)) .all() ) 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["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_descr(self) -> dict: """Description de l'état d'inscription""" 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" return { "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 return { "etat_in_cursem": "?", "inscription_courante": None, "inscription": inscription, "inscription_str": inscription, "situation": situation, } 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("%d/%m/%Y") 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 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): """Converts external representation of civilite to internal: 'M', 'F', or 'X' (and nothing else). Raises ScoValueError if conversion fails. """ 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}") 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(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", ondelete="CASCADE"), ) 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", ondelete="CASCADE"), ) # 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,""" 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 return d @classmethod def convert_dict_fields(cls, args: dict) -> dict: "Convert fields in the given dict. No other side effect" 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() if key == "civilite" or key == "civilite_etat_civil": value = input_civilite(value) 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 def from_dict(self, args: dict): # TODO à refactoriser dans une super-classe "update fields given in dict. Add to session but don't commit." args_dict = Admission.convert_dict_fields(args) args_dict.pop("adm_id", None) args_dict.pop("id", None) for key, value in args_dict.items(): if hasattr(self, key): setattr(self, key, value) db.session.add(self) # 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", ondelete="CASCADE"), ) 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)