# -*- 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 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"
    )

    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"}

    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.nomprenom}</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) -> "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) -> "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

    @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

    @cached_property
    def nomprenom(self, reverse=False) -> str:
        """Civilité/nom/prenom pour affichages: "M. Pierre Dupont"
        Si reverse, "Dupont Pierre", sans civilité.
        Prend l'identité courant et non celle de l'état civile 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()

    @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) -> tuple:
        "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 "", 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,
        )

    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

    @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) -> dict:
        """Représentation dictionnaire,
        compatible ScoDoc7 mais sans infos admission.
        Si restrict, cache les infos "personnelles" si pas permission ViewEtudData
        """
        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))
        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("%d/%m/%Y")
            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) -> 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 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"
        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: 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


# 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)


from app.models.formsemestre import FormSemestre
from app.models.modules import Module
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription