# -*- coding: UTF-8 -*
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet.  All rights reserved.
# See LICENSE
##############################################################################

# pylint génère trop de faux positifs avec les colonnes date:
# pylint: disable=no-member,not-an-iterable

"""ScoDoc models: formsemestre
"""
import datetime
from functools import cached_property

import flask_sqlalchemy
from flask import flash, g
from sqlalchemy import or_
from sqlalchemy.sql import text

import app.scodoc.sco_utils as scu
from app import db, log
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
from app.models.but_refcomp import (
    ApcAnneeParcours,
    ApcNiveau,
    ApcParcours,
    ApcParcoursNiveauCompetence,
    ApcReferentielCompetences,
    parcours_formsemestre,
)
from app.models.config import ScoDocSiteConfig
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.groups import GroupDescr, Partition
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
from app.models.modules import Module
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import sco_codes_parcours, sco_preferences
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV
from app.scodoc.sco_vdi import ApoEtapeVDI


class FormSemestre(db.Model):
    """Mise en oeuvre d'un semestre de formation"""

    __tablename__ = "notes_formsemestre"

    id = db.Column(db.Integer, primary_key=True)
    formsemestre_id = db.synonym("id")
    # dept_id est aussi dans la formation, ajouté ici pour
    # simplifier et accélérer les selects dans notesdb
    dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
    formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
    semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
    titre = db.Column(db.Text())
    date_debut = db.Column(db.Date())
    date_fin = db.Column(db.Date())
    etat = db.Column(
        db.Boolean(), nullable=False, default=True, server_default="true"
    )  # False si verrouillé
    modalite = db.Column(
        db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite")
    )  # "FI", "FAP", "FC", ...
    # gestion compensation sem DUT:
    gestion_compensation = db.Column(
        db.Boolean(), nullable=False, default=False, server_default="false"
    )
    # ne publie pas le bulletin XML ou JSON:
    bul_hide_xml = db.Column(
        db.Boolean(), nullable=False, default=False, server_default="false"
    )
    # Bloque le calcul des moyennes (générale et d'UE)
    block_moyennes = db.Column(
        db.Boolean(), nullable=False, default=False, server_default="false"
    )
    # Bloque le calcul de la moyenne générale (utile pour BUT)
    block_moyenne_generale = db.Column(
        db.Boolean(), nullable=False, default=False, server_default="false"
    )
    # semestres decales (pour gestion jurys):
    gestion_semestrielle = db.Column(
        db.Boolean(), nullable=False, default=False, server_default="false"
    )
    # couleur fond bulletins HTML:
    bul_bgcolor = db.Column(
        db.String(SHORT_STR_LEN), default="white", server_default="white"
    )
    # autorise resp. a modifier semestre:
    resp_can_edit = db.Column(
        db.Boolean(), nullable=False, default=False, server_default="false"
    )
    # autorise resp. a modifier slt les enseignants:
    resp_can_change_ens = db.Column(
        db.Boolean(), nullable=False, default=True, server_default="true"
    )
    # autorise les ens a creer des evals:
    ens_can_edit_eval = db.Column(
        db.Boolean(), nullable=False, default=False, server_default="False"
    )
    # code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'
    elt_sem_apo = db.Column(db.Text())  # peut être fort long !
    # code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'
    elt_annee_apo = db.Column(db.Text())

    # Relations:
    etapes = db.relationship(
        "FormSemestreEtape", cascade="all,delete", backref="formsemestre"
    )
    modimpls = db.relationship(
        "ModuleImpl",
        backref="formsemestre",
        lazy="dynamic",
    )
    etuds = db.relationship(
        "Identite",
        secondary="notes_formsemestre_inscription",
        viewonly=True,
        lazy="dynamic",
    )
    responsables = db.relationship(
        "User",
        secondary="notes_formsemestre_responsables",
        lazy=True,
        backref=db.backref("formsemestres", lazy=True),
    )
    partitions = db.relationship(
        "Partition",
        backref=db.backref("formsemestre", lazy=True),
        lazy="dynamic",
        order_by="Partition.numero",
    )
    # 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)

    # BUT
    parcours = db.relationship(
        "ApcParcours",
        secondary=parcours_formsemestre,
        lazy="subquery",
        backref=db.backref("formsemestres", lazy=True),
    )

    def __init__(self, **kwargs):
        super(FormSemestre, self).__init__(**kwargs)
        if self.modalite is None:
            self.modalite = FormationModalite.DEFAULT_MODALITE

    def __repr__(self):
        return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>"

    def to_dict(self, convert_objects=False) -> dict:
        """dict (compatible ScoDoc7).
        If convert_objects, convert all attributes to native types
        (suitable jor json encoding).
        """
        d = dict(self.__dict__)
        d.pop("_sa_instance_state", None)
        # ScoDoc7 output_formators: (backward compat)
        d["formsemestre_id"] = self.id
        d["titre_num"] = self.titre_num()
        if self.date_debut:
            d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
            d["date_debut_iso"] = self.date_debut.isoformat()
        else:
            d["date_debut"] = d["date_debut_iso"] = ""
        if self.date_fin:
            d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
            d["date_fin_iso"] = self.date_fin.isoformat()
        else:
            d["date_fin"] = d["date_fin_iso"] = ""
        d["responsables"] = [u.id for u in self.responsables]
        d["titre_formation"] = self.titre_formation()
        if convert_objects:
            d["parcours"] = [p.to_dict() for p in self.parcours]
            d["departement"] = self.departement.to_dict()
            d["formation"] = self.formation.to_dict()
            d["etape_apo"] = self.etapes_apo_str()
        return d

    def to_dict_api(self):
        """
        Un dict avec les informations sur le semestre destiné à l'api
        """
        d = dict(self.__dict__)
        d.pop("_sa_instance_state", None)
        d["annee_scolaire"] = self.annee_scolaire()
        if self.date_debut:
            d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
            d["date_debut_iso"] = self.date_debut.isoformat()
        else:
            d["date_debut"] = d["date_debut_iso"] = ""
        if self.date_fin:
            d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
            d["date_fin_iso"] = self.date_fin.isoformat()
        else:
            d["date_fin"] = d["date_fin_iso"] = ""
        d["departement"] = self.departement.to_dict()
        d["etape_apo"] = self.etapes_apo_str()
        d["formsemestre_id"] = self.id
        d["formation"] = self.formation.to_dict()
        d["parcours"] = [p.to_dict() for p in self.parcours]
        d["responsables"] = [u.id for u in self.responsables]
        d["titre_court"] = self.formation.acronyme
        d["titre_num"] = self.titre_num()
        d["session_id"] = self.session_id()
        return d

    def get_infos_dict(self) -> dict:
        """Un dict avec des informations sur le semestre
        pour les bulletins et autres templates
        (contenu compatible scodoc7 / anciens templates)
        """
        d = self.to_dict()
        d["anneescolaire"] = self.annee_scolaire_str()
        d["annee_debut"] = str(self.date_debut.year)
        d["annee"] = d["annee_debut"]
        d["annee_fin"] = str(self.date_fin.year)
        if d["annee_fin"] != d["annee_debut"]:
            d["annee"] += "-" + str(d["annee_fin"])
        d["mois_debut_ord"] = self.date_debut.month
        d["mois_fin_ord"] = self.date_fin.month
        # La période: considère comme "S1" (ou S3) les débuts en aout-sept-octobre
        # devrait sans doute pouvoir etre changé... XXX PIVOT
        d["periode"] = self.periode()
        if self.date_debut.month >= 8 and self.date_debut.month <= 10:
            d["periode"] = 1  # typiquement, début en septembre: S1, S3...
        else:
            d["periode"] = 2  # typiquement, début en février: S2, S4...
        d["titreannee"] = self.titre_annee()
        d["mois_debut"] = self.mois_debut()
        d["mois_fin"] = self.mois_fin()
        d["titremois"] = "%s %s  (%s - %s)" % (
            d["titre_num"],
            self.modalite or "",
            d["mois_debut"],
            d["mois_fin"],
        )
        d["session_id"] = self.session_id()
        d["etapes"] = self.etapes_apo_vdi()
        d["etapes_apo_str"] = self.etapes_apo_str()
        return d

    def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery:
        """UE des modules de ce semestre, triées par numéro.
        - Formations classiques: les UEs auxquelles appartiennent
          les modules mis en place dans ce semestre.
        - Formations APC / BUT: les UEs de la formation qui
            - ont le même numéro de semestre que ce formsemestre
            - sont associées à l'un des parcours de la formation (ou à aucun)

        """
        if self.formation.get_parcours().APC_SAE:
            sem_ues = UniteEns.query.filter_by(
                formation=self.formation, semestre_idx=self.semestre_id
            ).filter(
                (UniteEns.parcour == None)
                | (UniteEns.parcour_id.in_([p.id for p in self.parcours]))
            )
        else:
            sem_ues = db.session.query(UniteEns).filter(
                ModuleImpl.formsemestre_id == self.id,
                Module.id == ModuleImpl.module_id,
                UniteEns.id == Module.ue_id,
            )
        if not with_sport:
            sem_ues = sem_ues.filter(UniteEns.type != sco_codes_parcours.UE_SPORT)
        return sem_ues.order_by(UniteEns.numero)

    def query_ues_parcours_etud(self, etudid: int) -> flask_sqlalchemy.BaseQuery:
        """UE que suit l'étudiant dans ce semestre BUT
        en fonction du parcours dans lequel il est inscrit.

        Si voulez les UE d'un parcours, il est plus efficace de passer par
        `formation.query_ues_parcour(parcour)`.
        """
        return self.query_ues().filter(
            FormSemestreInscription.etudid == etudid,
            FormSemestreInscription.formsemestre == self,
            UniteEns.niveau_competence_id == ApcNiveau.id,
            ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
            ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
            ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
        )

    @cached_property
    def modimpls_sorted(self) -> list[ModuleImpl]:
        """Liste des modimpls du semestre (y compris bonus)
        - triée par type/numéro/code en APC
        - triée par numéros d'UE/matières/modules pour les formations standard.
        """
        modimpls = self.modimpls.all()
        if self.formation.is_apc():
            modimpls.sort(
                key=lambda m: (
                    m.module.module_type or 0,
                    m.module.numero or 0,
                    m.module.code or 0,
                )
            )
        else:
            modimpls.sort(
                key=lambda m: (
                    m.module.ue.numero or 0,
                    m.module.matiere.numero or 0,
                    m.module.numero or 0,
                    m.module.code or "",
                )
            )
        return modimpls

    def modimpls_parcours(self, parcours: ApcParcours) -> list[ModuleImpl]:
        """Liste des modimpls du semestre (sans les bonus (?)) dans le parcours donné.
        - triée par type/numéro/code ??
        """
        cursor = db.session.execute(
            text(
                """
            SELECT modimpl.id
            FROM notes_moduleimpl modimpl, notes_modules mod,
            parcours_modules pm, parcours_formsemestre pf
            WHERE modimpl.formsemestre_id = :formsemestre_id
            AND modimpl.module_id = mod.id
            AND pm.module_id = mod.id
            AND pm.parcours_id = pf.parcours_id
            AND pf.parcours_id = :parcours_id
            AND pf.formsemestre_id = :formsemestre_id
            """
            ),
            {"formsemestre_id": self.id, "parcours_id": parcours.id},
        )
        return [ModuleImpl.query.get(modimpl_id) for modimpl_id in cursor]

    def can_be_edited_by(self, user):
        """Vrai si user peut modifier ce semestre"""
        if not user.has_permission(Permission.ScoImplement):  # pas chef
            if not self.resp_can_edit or user.id not in [
                resp.id for resp in self.responsables
            ]:
                return False

        return True

    def est_courant(self) -> bool:
        """Vrai si la date actuelle (now) est dans le semestre
        (les dates de début et fin sont incluses)
        """
        today = datetime.date.today()
        return self.date_debut <= today <= self.date_fin

    def contient_periode(self, date_debut, date_fin) -> bool:
        """Vrai si l'intervalle [date_debut, date_fin] est
        inclus dans le semestre.
        (les dates de début et fin sont incluses)
        """
        return (self.date_debut <= date_debut) and (date_fin <= self.date_fin)

    def est_sur_une_annee(self):
        """Test si sem est entièrement sur la même année scolaire.
        (ce n'est pas obligatoire mais si ce n'est pas le
        cas les exports Apogée risquent de mal fonctionner)
        Pivot au 1er août par défaut.
        """
        if self.date_debut > self.date_fin:
            flash(f"Dates début/fin inversées pour le semestre {self.titre_annee()}")
            log(f"Warning: semestre {self.id} begins after ending !")
        annee_debut = self.date_debut.year
        month_debut_annee = ScoDocSiteConfig.get_month_debut_annee_scolaire()
        if self.date_debut.month < month_debut_annee:
            # début sur l'année scolaire précédente (juillet inclus par défaut)
            annee_debut -= 1
        annee_fin = self.date_fin.year
        if self.date_fin.month < (month_debut_annee + 1):
            # 9 (sept) pour autoriser un début en sept et une fin en août
            annee_fin -= 1
        return annee_debut == annee_fin

    def est_decale(self):
        """Vrai si semestre "décalé"
        c'est à dire semestres impairs commençant (par défaut)
        entre janvier et juin et les pairs entre juillet et décembre.
        """
        if self.semestre_id <= 0:
            return False  # formations sans semestres
        return (
            # impair
            (
                self.semestre_id % 2
                and self.date_debut.month < scu.MONTH_DEBUT_ANNEE_SCOLAIRE
            )
            or
            # pair
            (
                (not self.semestre_id % 2)
                and self.date_debut.month >= scu.MONTH_DEBUT_ANNEE_SCOLAIRE
            )
        )

    @classmethod
    def comp_periode(
        cls,
        date_debut: datetime,
        mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
        mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
        jour_pivot_annee=1,
        jour_pivot_periode=1,
    ):
        """Calcule la session associée à un formsemestre commençant en date_debut
        sous la forme (année, période)
            année: première année de l'année scolaire
            période = 1 (première période de l'année scolaire, souvent automne)
                   ou 2 (deuxième période de l'année scolaire, souvent printemps)
        Les quatre derniers paramètres forment les dates pivots pour l'année
        (1er août par défaut) et pour la période (1er décembre par défaut).

        Les calculs se font à partir de la date de début indiquée.
        Exemples dans tests/unit/test_periode

        Implémentation:
        Cas à considérer pour le calcul de la période

        pa < pp       -----------------|-------------------|---------------->
                        (A-1,  P:2)   pa    (A, P:1)      pp    (A, P:2)
        pp < pa       -----------------|-------------------|---------------->
                        (A-1,  P:1)   pp     (A-1, P:2)   pa    (A, P:1)
        """
        pivot_annee = 100 * mois_pivot_annee + jour_pivot_annee
        pivot_periode = 100 * mois_pivot_periode + jour_pivot_periode
        pivot_sem = 100 * date_debut.month + date_debut.day
        if pivot_sem < pivot_annee:
            annee = date_debut.year - 1
        else:
            annee = date_debut.year
        if pivot_annee < pivot_periode:
            if pivot_sem < pivot_annee or pivot_sem >= pivot_periode:
                periode = 2
            else:
                periode = 1
        else:
            if pivot_sem < pivot_periode or pivot_sem >= pivot_annee:
                periode = 1
            else:
                periode = 2
        return annee, periode

    def periode(self) -> int:
        """La période:
        * 1 : première période: automne à Paris
        * 2 : deuxième période, printemps à Paris
        """
        return FormSemestre.comp_periode(
            self.date_debut,
            mois_pivot_annee=ScoDocSiteConfig.get_month_debut_annee_scolaire(),
            mois_pivot_periode=ScoDocSiteConfig.get_month_debut_periode2(),
        )

    def etapes_apo_vdi(self) -> list[ApoEtapeVDI]:
        "Liste des vdis"
        # was read_formsemestre_etapes
        return [e.as_apovdi() for e in self.etapes if e.etape_apo]

    def etapes_apo_str(self) -> str:
        """Chaine décrivant les étapes de ce semestre
        ex: "V1RT, V1RT3, V1RT4"
        """
        if not self.etapes:
            return ""
        return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape]))

    def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
        """Calcule la liste des regroupements cohérents d'UE impliquant ce
        formsemestre.
        Pour une année donnée: l'étudiant est inscrit dans ScoDoc soit dans le semestre
        impair, soit pair, soit les deux (il est rare mais pas impossible d'avoir une
        inscription seulement en semestre pair, par exemple suite à un transfert ou un
        arrêt temporaire du cursus).

        1. Déterminer l'*autre* formsemestre: semestre précédent ou suivant de la même
            année, formation compatible (même référentiel de compétence) dans lequel
            l'étudiant est inscrit.

        2. Construire les couples d'UE (regroupements cohérents): apparier les UE qui
            ont le même `ApcParcoursNiveauCompetence`.
        """
        if not self.formation.is_apc():
            return []
        raise NotImplementedError()  # XXX

    def responsables_str(self, abbrev_prenom=True) -> str:
        """chaîne "J. Dupond, X. Martin"
        ou  "Jacques Dupond, Xavier Martin"
        """
        # was "nomcomplet"
        if not self.responsables:
            return ""
        if abbrev_prenom:
            return ", ".join([u.get_prenomnom() for u in self.responsables])
        else:
            return ", ".join([u.get_nomcomplet() for u in self.responsables])

    def est_responsable(self, user):
        "True si l'user est l'un des responsables du semestre"
        return user.id in [u.id for u in self.responsables]

    def annee_scolaire(self) -> int:
        """L'année de début de l'année scolaire.
        Par exemple, 2022 si le semestre va de septembre 2022 à février 2023."""
        return scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)

    def annee_scolaire_str(self):
        "2021 - 2022"
        return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month)

    def mois_debut(self) -> str:
        "Oct  2021"
        return f"{MONTH_NAMES_ABBREV[self.date_debut.month - 1]} {self.date_debut.year}"

    def mois_fin(self) -> str:
        "Jul  2022"
        return f"{MONTH_NAMES_ABBREV[self.date_fin.month - 1]} {self.date_fin.year}"

    def session_id(self) -> str:
        """identifiant externe de semestre de formation
        Exemple:  RT-DUT-FI-S1-ANNEE

        DEPT-TYPE-MODALITE+-S?|SPECIALITE

        TYPE=DUT|LP*|M*
        MODALITE=FC|FI|FA (si plusieurs, en inverse alpha)

        SPECIALITE=[A-Z]+   EON,ASSUR, ... (si pas Sn ou SnD)

        ANNEE=annee universitaire de debut (exemple: un S2 de 2013-2014 sera S2-2013)
        """
        prefs = sco_preferences.SemPreferences(dept_id=self.dept_id)
        imputation_dept = prefs["ImputationDept"]
        if not imputation_dept:
            imputation_dept = prefs["DeptName"]
        imputation_dept = imputation_dept.upper()
        parcours_name = self.formation.get_parcours().NAME
        modalite = self.modalite
        # exception pour code Apprentissage:
        modalite = (modalite or "").replace("FAP", "FA").replace("APP", "FA")
        if self.semestre_id > 0:
            decale = "D" if self.est_decale() else ""
            semestre_id = f"S{self.semestre_id}{decale}"
        else:
            semestre_id = self.formation.code_specialite or ""
        annee_sco = str(
            scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
        )
        return scu.sanitize_string(
            f"{imputation_dept}-{parcours_name}-{modalite}-{semestre_id}-{annee_sco}"
        )

    def titre_annee(self) -> str:
        """Le titre avec l'année
        'DUT Réseaux et Télécommunications semestre 3 FAP  2020-2021'
        """
        titre_annee = (
            f"{self.titre_num()} {self.modalite or ''}  {self.date_debut.year}"
        )
        if self.date_fin.year != self.date_debut.year:
            titre_annee += "-" + str(self.date_fin.year)
        return titre_annee

    def titre_formation(self):
        """Titre avec formation, court, pour passerelle: "BUT R&T"
        (méthode de formsemestre car on pourrait ajouter le semestre, ou d'autres infos, à voir)
        """
        return self.formation.acronyme

    def titre_mois(self) -> str:
        """Le titre et les dates du semestre, pour affichage dans des listes
        Ex: "BUT QLIO (PN 2022) semestre 1 FI (Sept 2022 - Jan 2023)"
        """
        return f"""{self.titre_num()} {self.modalite or ''} ({
            scu.MONTH_NAMES_ABBREV[self.date_debut.month-1]} {
                self.date_debut.year} - {
            scu.MONTH_NAMES_ABBREV[self.date_fin.month -1]} {
                self.date_fin.year})"""

    def titre_num(self) -> str:
        """Le titre et le semestre, ex ""DUT Informatique semestre 2"" """
        if self.semestre_id == sco_codes_parcours.NO_SEMESTRE_ID:
            return self.titre
        return f"{self.titre} {self.formation.get_parcours().SESSION_NAME} {self.semestre_id}"

    def sem_modalite(self) -> str:
        """Le semestre et la modalité, ex "S2 FI" ou "S3 APP" """
        if self.semestre_id > 0:
            descr_sem = f"S{self.semestre_id}"
        else:
            descr_sem = ""
        if self.modalite:
            descr_sem += " " + self.modalite
        return descr_sem

    def get_abs_count(self, etudid):
        """Les comptes d'absences de cet étudiant dans ce semestre:
        tuple (nb abs, nb abs justifiées)
        Utilise un cache.
        """
        from app.scodoc import sco_abs

        return sco_abs.get_abs_count_in_interval(
            etudid, self.date_debut.isoformat(), self.date_fin.isoformat()
        )

    def get_codes_apogee(self, category=None) -> set[str]:
        """Les codes Apogée (codés en base comme "VRT1,VRT2")
        category: None: tous, "etapes": étapes associées, "sem: code semestre", "annee": code annuel
        """
        codes = set()
        if category is None or category == "etapes":
            codes |= {e.etape_apo for e in self.etapes if e}
        if (category is None or category == "sem") and self.elt_sem_apo:
            codes |= {x.strip() for x in self.elt_sem_apo.split(",") if x}
        if (category is None or category == "annee") and self.elt_annee_apo:
            codes |= {x.strip() for x in self.elt_annee_apo.split(",") if x}
        return codes

    def get_inscrits(self, include_demdef=False, order=False) -> list[Identite]:
        """Liste des étudiants inscrits à ce semestre
        Si include_demdef, tous les étudiants, avec les démissionnaires
        et défaillants.
        Si order, tri par clé sort_key
        """
        if include_demdef:
            etuds = [ins.etud for ins in self.inscriptions]
        else:
            etuds = [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT]
        if order:
            etuds.sort(key=lambda e: e.sort_key)
        return etuds

    @cached_property
    def etudids_actifs(self) -> set:
        "Set des etudids inscrits non démissionnaires et non défaillants"
        return {ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT}

    @cached_property
    def etuds_inscriptions(self) -> dict:
        """Map { etudid : inscription } (incluant DEM et DEF)"""
        return {ins.etud.id: ins for ins in self.inscriptions}

    def setup_parcours_groups(self) -> None:
        """Vérifie et créee si besoin la partition et les groupes de parcours BUT."""
        if not self.formation.is_apc():
            return
        partition = Partition.query.filter_by(
            formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS
        ).first()
        if partition is None:
            # Création de la partition de parcours
            partition = Partition(
                formsemestre_id=self.id,
                partition_name=scu.PARTITION_PARCOURS,
                numero=-1,
                groups_editable=False,
            )
            db.session.add(partition)
            db.session.flush()  # pour avoir un id
            flash("Partition Parcours créée.")

        for parcour in self.parcours:
            if parcour.code:
                group = GroupDescr.query.filter_by(
                    partition_id=partition.id, group_name=parcour.code
                ).first()
                if not group:
                    partition.groups.append(GroupDescr(group_name=parcour.code))
        db.session.flush()
        # S'il reste des groupes de parcours qui ne sont plus dans le semestre
        # et qui n'ont pas d'inscrits, supprime-les.
        for group in GroupDescr.query.filter_by(partition_id=partition.id):
            if (group.group_name not in (p.code for p in self.parcours)) and (
                len(
                    [
                        inscr
                        for inscr in self.inscriptions
                        if (inscr.parcour is not None)
                        and inscr.parcour.code == group.group_name
                    ]
                )
                == 0
            ):
                flash(f"suppression du groupe de parcours {group.group_name}")
                db.session.delete(group)

        db.session.commit()

    def update_inscriptions_parcours_from_groups(self) -> None:
        """Met à jour les inscriptions dans les parcours du semestres en
        fonction des groupes de parcours.
        Les groupes de parcours sont ceux de la partition scu.PARTITION_PARCOURS
        et leur nom est le code du parcours (eg "Cyber").
        """
        partition = Partition.query.filter_by(
            formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS
        ).first()
        if partition is None:  # pas de partition de parcours
            return

        # Efface les inscriptions aux parcours:
        db.session.execute(
            text(
                """UPDATE notes_formsemestre_inscription
                    SET parcour_id=NULL
                    WHERE formsemestre_id=:formsemestre_id
                """
            ),
            {
                "formsemestre_id": self.id,
            },
        )
        # Inscrit les étudiants des groupes de parcours:
        for group in partition.groups:
            query = (
                ApcParcours.query.filter_by(code=group.group_name)
                .join(ApcReferentielCompetences)
                .filter_by(dept_id=g.scodoc_dept_id)
            )
            if query.count() != 1:
                log(
                    f"""update_inscriptions_parcours_from_groups: {
                        query.count()} parcours with code {group.group_name}"""
                )
                continue
            parcour = query.first()
            db.session.execute(
                text(
                    """UPDATE notes_formsemestre_inscription ins
                    SET parcour_id=:parcour_id
                    FROM group_membership gm
                    WHERE formsemestre_id=:formsemestre_id
                    AND gm.etudid = ins.etudid
                    AND gm.group_id = :group_id
                    """
                ),
                {
                    "formsemestre_id": self.id,
                    "parcour_id": parcour.id,
                    "group_id": group.id,
                },
            )
        db.session.commit()

    def etud_validations_description_html(self, etudid: int) -> str:
        """Description textuelle des validations de jury de cet étudiant dans ce semestre"""
        from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE

        vals_sem = ScolarFormSemestreValidation.query.filter_by(
            etudid=etudid, formsemestre_id=self.id, ue_id=None
        ).all()
        vals_ues = (
            ScolarFormSemestreValidation.query.filter_by(
                etudid=etudid, formsemestre_id=self.id
            )
            .join(UniteEns)
            .order_by(UniteEns.numero)
            .all()
        )
        # Validations BUT:
        vals_rcues = (
            ApcValidationRCUE.query.filter_by(etudid=etudid, formsemestre_id=self.id)
            .join(UniteEns, ApcValidationRCUE.ue1)
            .order_by(UniteEns.numero)
            .all()
        )
        vals_annee = (
            ApcValidationAnnee.query.filter_by(
                etudid=etudid,
                annee_scolaire=self.annee_scolaire(),
            )
            .join(ApcValidationAnnee.formsemestre)
            .join(FormSemestre.formation)
            .filter(Formation.formation_code == self.formation.formation_code)
            .all()
        )
        H = []
        for vals in (vals_sem, vals_ues, vals_rcues, vals_annee):
            if vals:
                H.append(
                    f"""<ul><li>{"</li><li>".join(str(x) for x in vals)}</li></ul>"""
                )
        return "\n".join(H)

    def etud_set_all_missing_notes(self, etud: Identite, value=None) -> int:
        """Met toutes les notes manquantes de cet étudiant dans ce semestre
        (ie dans toutes les évaluations des modules auxquels il est inscrit et n'a pas de note)
        à la valeur donnée par value, qui est en général "ATT", "ABS", "EXC".
        """
        from app.scodoc import sco_saisie_notes

        inscriptions = (
            ModuleImplInscription.query.filter_by(etudid=etud.id)
            .join(ModuleImpl)
            .filter_by(formsemestre_id=self.id)
        )
        nb_recorded = 0
        for inscription in inscriptions:
            for evaluation in inscription.modimpl.evaluations:
                if evaluation.get_etud_note(etud) is None:
                    if not sco_saisie_notes.do_evaluation_set_etud_note(
                        evaluation, etud, value
                    ):
                        raise ScoValueError(
                            "erreur lors de l'enregistrement de la note"
                        )
                    nb_recorded += 1
        return nb_recorded


# Association id des utilisateurs responsables (aka directeurs des etudes) du semestre
notes_formsemestre_responsables = db.Table(
    "notes_formsemestre_responsables",
    db.Column(
        "formsemestre_id",
        db.Integer,
        db.ForeignKey("notes_formsemestre.id"),
    ),
    db.Column("responsable_id", db.Integer, db.ForeignKey("user.id")),
)


class FormSemestreEtape(db.Model):
    """Étape Apogée associée au semestre"""

    __tablename__ = "notes_formsemestre_etapes"
    id = db.Column(db.Integer, primary_key=True)
    formsemestre_id = db.Column(
        db.Integer,
        db.ForeignKey("notes_formsemestre.id"),
    )
    # etape_apo aurait du etre not null, mais oublié
    etape_apo = db.Column(db.String(APO_CODE_STR_LEN), index=True)

    def __bool__(self):
        "Etape False if code empty"
        return self.etape_apo is not None and (len(self.etape_apo) > 0)

    def __repr__(self):
        return f"<Etape {self.id} apo={self.etape_apo!r}>"

    def as_apovdi(self):
        return ApoEtapeVDI(self.etape_apo)


class FormationModalite(db.Model):
    """Modalités de formation, utilisées pour la présentation
    (grouper les semestres, générer des codes, etc.)
    """

    __tablename__ = "notes_form_modalites"

    DEFAULT_MODALITE = "FI"

    id = db.Column(db.Integer, primary_key=True)
    modalite = db.Column(
        db.String(SHORT_STR_LEN),
        unique=True,
        index=True,
        default=DEFAULT_MODALITE,
        server_default=DEFAULT_MODALITE,
    )  # code
    titre = db.Column(db.Text())  # texte explicatif
    # numero = ordre de presentation)
    numero = db.Column(db.Integer)

    @staticmethod
    def insert_modalites():
        """Create default modalities"""
        numero = 0
        try:
            for (code, titre) in (
                (FormationModalite.DEFAULT_MODALITE, "Formation Initiale"),
                ("FAP", "Apprentissage"),
                ("FC", "Formation Continue"),
                ("DEC", "Formation Décalées"),
                ("LIC", "Licence"),
                ("CPRO", "Contrats de Professionnalisation"),
                ("DIST", "À distance"),
                ("ETR", "À l'étranger"),
                ("EXT", "Extérieur"),
                ("OTHER", "Autres formations"),
            ):
                modalite = FormationModalite.query.filter_by(modalite=code).first()
                if modalite is None:
                    modalite = FormationModalite(
                        modalite=code, titre=titre, numero=numero
                    )
                    db.session.add(modalite)
                    numero += 1
            db.session.commit()
        except:
            db.session.rollback()
            raise


class FormSemestreUECoef(db.Model):
    """Coef des UE capitalisees arrivant dans ce semestre"""

    __tablename__ = "notes_formsemestre_uecoef"
    __table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id"),)

    id = db.Column(db.Integer, primary_key=True)
    formsemestre_uecoef_id = db.synonym("id")
    formsemestre_id = db.Column(
        db.Integer,
        db.ForeignKey("notes_formsemestre.id"),
        index=True,
    )
    ue_id = db.Column(
        db.Integer,
        db.ForeignKey("notes_ue.id"),
        index=True,
    )
    coefficient = db.Column(db.Float, nullable=False)


class FormSemestreUEComputationExpr(db.Model):
    """Formules utilisateurs pour calcul moyenne UE (désactivées en 9.2+)."""

    __tablename__ = "notes_formsemestre_ue_computation_expr"
    __table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id"),)

    id = db.Column(db.Integer, primary_key=True)
    notes_formsemestre_ue_computation_expr_id = db.synonym("id")
    formsemestre_id = db.Column(
        db.Integer,
        db.ForeignKey("notes_formsemestre.id"),
    )
    ue_id = db.Column(
        db.Integer,
        db.ForeignKey("notes_ue.id"),
    )
    # formule de calcul moyenne
    computation_expr = db.Column(db.Text())


class FormSemestreCustomMenu(db.Model):
    """Menu custom associe au semestre"""

    __tablename__ = "notes_formsemestre_custommenu"

    id = db.Column(db.Integer, primary_key=True)
    custommenu_id = db.synonym("id")
    formsemestre_id = db.Column(
        db.Integer,
        db.ForeignKey("notes_formsemestre.id"),
    )
    title = db.Column(db.Text())
    url = db.Column(db.Text())
    idx = db.Column(db.Integer, default=0, server_default="0")  #  rang dans le menu


class FormSemestreInscription(db.Model):
    """Inscription à un semestre de formation"""

    __tablename__ = "notes_formsemestre_inscription"
    __table_args__ = (db.UniqueConstraint("formsemestre_id", "etudid"),)

    id = db.Column(db.Integer, primary_key=True)
    formsemestre_inscription_id = db.synonym("id")

    etudid = db.Column(
        db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE"), index=True
    )
    formsemestre_id = db.Column(
        db.Integer,
        db.ForeignKey("notes_formsemestre.id"),
        index=True,
    )
    etud = db.relationship(
        Identite,
        backref=db.backref("formsemestre_inscriptions", cascade="all, delete-orphan"),
    )
    formsemestre = db.relationship(
        FormSemestre,
        backref=db.backref(
            "inscriptions",
            cascade="all, delete-orphan",
            order_by="FormSemestreInscription.etudid",
        ),
    )
    # I inscrit, D demission en cours de semestre, DEF si "defaillant"
    etat = db.Column(db.String(CODE_STR_LEN), index=True)
    # Etape Apogée d'inscription (ajout 2020)
    etape = db.Column(db.String(APO_CODE_STR_LEN))
    # Parcours (pour les BUT)
    parcour_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), index=True)
    parcour = db.relationship(ApcParcours)

    def __repr__(self):
        return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={
            self.formsemestre_id} etat={self.etat} {
            ('parcours='+str(self.parcour)) if self.parcour else ''}>"""


class NotesSemSet(db.Model):
    """semsets: ensemble de formsemestres pour exports Apogée"""

    __tablename__ = "notes_semset"

    id = db.Column(db.Integer, primary_key=True)
    semset_id = db.synonym("id")
    dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"))

    title = db.Column(db.Text)
    annee_scolaire = db.Column(db.Integer, nullable=True, default=None)
    sem_id = db.Column(db.Integer, nullable=False, default=0)
    "période: 0 (année), 1 (Simpair), 2 (Spair)"


# Association: many to many
notes_semset_formsemestre = db.Table(
    "notes_semset_formsemestre",
    db.Column("formsemestre_id", db.Integer, db.ForeignKey("notes_formsemestre.id")),
    db.Column(
        "semset_id",
        db.Integer,
        db.ForeignKey("notes_semset.id", ondelete="CASCADE"),
        nullable=False,
    ),
    db.UniqueConstraint("formsemestre_id", "semset_id"),
)
