##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
# See LICENSE
##############################################################################

"""Jury BUT: logique de gestion

Utilisation:
    1) chargement page jury, pour un étudiant et un formsemestre BUT quelconque
    - DecisionsProposeesAnnee(formsemestre)
      cherche l'autre formsemestre de la même année scolaire (peut ne pas exister)
      cherche les RCUEs de l'année (BUT1, 2, 3)
        pour un redoublant, le RCUE peut considérer un formsemestre d'une année antérieure.

    on instancie des DecisionsProposees pour les
        différents éléments (UEs, RCUEs, Année, Diplôme)
        Cela donne
            - les codes possibles (dans .codes)
            - le code actuel si une décision existe déjà (dans code_valide)
            - pour les UEs, le rcue s'il y en a un)

    2) Validation pour l'utilisateur (form)) => enregistrement code
            - on vérifie que le code soumis est bien dans les codes possibles
            - on enregistre la décision (dans ScolarFormSemestreValidation pour les UE,
                ApcValidationRCUE pour les RCUE, et ApcValidationAnnee pour les années)
            - Si RCUE validé, on déclenche d'éventuelles validations:
            ("La validation des deux UE du niveau d'une compétence emporte la validation
            de l'ensemble des UE du niveau inférieur de cette même compétence.")

Les jurys de semestre BUT impairs entrainent systématiquement la génération d'une
autorisation d'inscription dans le semestre pair suivant: `ScolarAutorisationInscription`.
Les jurys de semestres pairs non (S2, S4, S6): il y a une décision sur l'année (ETP)
    - autorisation en S_2n+1 (S3 ou S5) si: ADM, ADJ, PASD, PAS1CN
    - autorisation en S2n-1 (S1, S3 ou S5) si: RED
    - rien si pour les autres codes d'année.

Le formulaire permet de choisir des codes d'UE, RCUE et Année (ETP).
Mais normalement, les codes d'UE sont à choisir: les RCUE et l'année s'en déduisent.
Si l'utilisateur coche "décision manuelle", il peut alors choisir les codes RCUE et années.

La soumission du formulaire:
    - etud, formation
    - UEs: [(formsemestre, ue, code), ...]
    - RCUE: [(formsemestre, ue, code), ...] le formsemestre est celui d'indice pair du niveau
        (S2, S4 ou S6), il sera regoupé avec celui impair de la même année ou de la suivante.
    - Année: [(formsemestre, code)]

DecisionsProposeesAnnee:
    si 1/2 des rcue et aucun < 8 + pour S5 condition sur les UE de BUT1 et BUT2
    => charger les DecisionsProposeesRCUE

DecisionsProposeesRCUE: les RCUEs pour cette année
    validable, compensable, ajourné. Utilise classe RegroupementCoherentUE

DecisionsProposeesUE: décisions de jury sur une UE du BUT
    initialisation sans compensation (ue isolée), mais
    DecisionsProposeesRCUE appelera .set_compensable()
    si on a la possibilité de la compenser dans le RCUE.
"""
from datetime import datetime
import html
import re

import numpy as np
from flask import flash, g, url_for

from app import db
from app import log
from app.but.cursus_but import EtudCursusBUT
from app.but.rcue import RegroupementCoherentUE
from app.comp.res_but import ResultatsSemestreBUT
from app.comp import res_sem

from app.models.but_refcomp import (
    ApcCompetence,
    ApcNiveau,
    ApcParcours,
)
from app.models import Evaluation, ModuleImpl, Scolog, ScolarAutorisationInscription
from app.models.but_validations import (
    ApcValidationAnnee,
    ApcValidationRCUE,
)
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import sco_cache
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.codes_cursus import (
    code_rcue_validant,
    BUT_CODES_ORDER,
    CODES_RCUE_VALIDES,
    CODES_UE_VALIDES,
    RED,
    UE_STANDARD,
)
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError


class NoRCUEError(ScoValueError):
    """Erreur en cas de RCUE manquant"""

    def __init__(self, deca: "DecisionsProposeesAnnee", ue: UniteEns):
        if all(u.niveau_competence for u in deca.ues_pair):
            warning_pair = ""
        else:
            warning_pair = """<div class="warning">certaines UE du semestre pair ne sont pas associées à un niveau de compétence</div>"""
        if all(u.niveau_competence for u in deca.ues_impair):
            warning_impair = ""
        else:
            warning_impair = """<div class="warning">certaines UE du semestre impair ne sont pas associées à un niveau de compétence</div>"""
        msg = (
            f"""<h3>Pas de RCUE pour l'UE {ue.acronyme}</h3>
            {warning_impair}
            {warning_pair}
            <div><b>UE {ue.acronyme}</b>: niveau {html.escape(str(ue.niveau_competence))}</div>
            <div><b>UEs impaires:</b> {html.escape(', '.join(str(u.niveau_competence or "pas de niveau")
                for u in deca.ues_impair))}
            </div>
            """
            + deca.infos()
        )
        super().__init__(msg)


class DecisionsProposees:
    """Une décision de jury proposé, constituée d'une liste de codes et d'une explication.
    Super-classe, spécialisée pour les UE, les RCUE, les années et le diplôme.

    validation : None ou une instance d'une classe avec un champ code
                ApcValidationRCUE, ApcValidationAnnee ou ScolarFormSemestreValidation
    """

    # Codes toujours proposés sauf si include_communs est faux:
    codes_communs = [
        sco_codes.RAT,
        sco_codes.DEF,
        sco_codes.ABAN,
        sco_codes.DEM,
        sco_codes.UEBSL,
    ]

    def __init__(
        self,
        etud: Identite = None,
        code: str | list[str] | None = None,
        explanation="",
        code_valide=None,
        include_communs=True,
    ):
        self.etud = etud
        self.codes = []
        "Les codes attribuables par ce jury"
        if include_communs:
            self.codes = self.codes_communs.copy()
        if isinstance(code, list):
            self.codes = code + self.codes
        elif code is not None:
            self.codes = [code] + self.codes
        self.validation = None
        "Validation enregistrée"
        self.code_valide: str = code_valide
        "Code décision actuel enregistré"
        # S'assure que le code enregistré est toujours présent dans le menu
        if self.code_valide and self.code_valide not in self.codes:
            self.codes.append(self.code_valide)
        self.explanation: str = explanation
        "Explication à afficher à côté de la décision"
        self.recorded = False
        "true si la décision vient d'être enregistrée"

    def __repr__(self) -> str:
        return f"""<{self.__class__.__name__} valid={self.code_valide
        } codes={self.codes} explanation={self.explanation}>"""


class DecisionsProposeesAnnee(DecisionsProposees):
    """Décisions de jury sur une année (ETP) du BUT

    Le texte:
    La poursuite d'études dans un semestre pair d'une même année est de droit
    pour tout étudiant. La poursuite d'études dans un semestre impair est
    possible si et seulement si l'étudiant a obtenu :
        - la moyenne à plus de la moitié des regroupements cohérents d'UE;
        - et une moyenne égale ou supérieure à 8 sur 20 à chaque RCUE.
    La poursuite d'études dans le semestre 5 nécessite de plus la validation
    de toutes les UE des semestres 1 et 2 dans les conditions de validation
    des points 4.3 (moy_ue >= 10) et 4.4 (compensation rcue), ou par décision
    de jury.
    """

    # Codes toujours proposés sauf si include_communs est faux:
    codes_communs = [
        sco_codes.RAT,
        sco_codes.RED,  # TODO temporaire 9.4.93: propose toujours RED
        sco_codes.ABAN,
        sco_codes.ABL,
        sco_codes.ATJ,
        sco_codes.DEF,
        sco_codes.DEM,
        sco_codes.EXCLU,
        sco_codes.NAR,
    ]

    def __init__(
        self,
        etud: Identite,
        formsemestre: FormSemestre,
    ):
        assert formsemestre.formation.is_apc()
        if formsemestre.formation.referentiel_competence is None:
            raise ScoNoReferentielCompetences(formation=formsemestre.formation)
        super().__init__(etud=etud)
        self.formsemestre = formsemestre
        "le formsemestre d'origine, utilisé pour construire ce deca"
        # Si on part d'un semestre IMPAIR, il n'y aura pas de décision année proposée
        # (mais on pourra évidemment valider des UE et même des RCUE)
        self.jury_annuel: bool = formsemestre.semestre_id in (2, 4, 6)
        "vrai si jury de fin d'année scolaire (sem. pair, propose code annuel)"
        self.annee_but = (formsemestre.semestre_id + 1) // 2
        "le rang de l'année dans le BUT: 1, 2, 3"
        assert self.annee_but in (1, 2, 3)
        # ---- inscription et parcours
        inscription = formsemestre.etuds_inscriptions.get(etud.id)
        if inscription is None:
            raise ValueError("Etudiant non inscrit au semestre")
        self.inscription_etat = inscription.etat
        "état de l'inscription dans le semestre origine"
        self.parcour = inscription.parcour
        "Le parcours considéré, qui est celui de l'étudiant dans le formsemestre origine"
        self.formsemestre_impair, self.formsemestre_pair = self.comp_formsemestres(
            formsemestre
        )
        # ---- résultats et UEs en cours cette année:
        self.res_impair: ResultatsSemestreBUT = (
            res_sem.load_formsemestre_results(self.formsemestre_impair)
            if self.formsemestre_impair
            else None
        )
        self.res_pair: ResultatsSemestreBUT = (
            res_sem.load_formsemestre_results(self.formsemestre_pair)
            if self.formsemestre_pair
            else None
        )
        self.cur_ues_impair = (
            list_ue_parcour_etud(
                self.formsemestre_impair, self.etud, self.parcour, self.res_impair
            )
            if self.formsemestre_impair
            else []
        )
        self.cur_ues_pair = (
            list_ue_parcour_etud(
                self.formsemestre_pair, self.etud, self.parcour, self.res_pair
            )
            if self.formsemestre_pair
            else []
        )
        # ---- Niveaux et RCUEs
        niveaux_by_parcours = formsemestre.formation.referentiel_competence.get_niveaux_by_parcours(
            self.annee_but, [self.parcour] if self.parcour else None
        )[
            1
        ]
        self.niveaux_competences = niveaux_by_parcours["TC"] + (
            niveaux_by_parcours[self.parcour.id] if self.parcour else []
        )
        """Les niveaux à valider pour cet étudiant dans cette année, compte tenu de son parcours.
        Liste non triée des niveaux de compétences associés à cette année pour cet étudiant.
        = niveaux du tronc commun + niveau du parcours de l'étudiant.
        """
        self.rcue_by_niveau = self._compute_rcues_annee()
        """RCUEs de l'année
        (peuvent être construits avec des UEs validées antérieurement: redoublants
        avec UEs capitalisées, validation "antérieures")
        """
        # ---- Décision année et autorisation
        self.autorisations_recorded = False
        "vrai si on a enregistré l'autorisation de passage"
        self.validation = ApcValidationAnnee.query.filter_by(
            etudid=self.etud.id,
            ordre=self.annee_but,
            referentiel_competence_id=self.formsemestre.formation.referentiel_competence_id,
        ).first()
        "Validation actuellement enregistrée pour cette année BUT"
        self.code_valide = self.validation.code if self.validation is not None else None
        "Le code jury annuel enregistré, ou None"

        # ---- Décisions d'UEs
        self.decisions_ues = {
            rcue.ue_1.id: DecisionsProposeesUE(
                etud, self.formsemestre_impair, rcue, False, self.inscription_etat
            )
            for rcue in self.rcue_by_niveau.values()
            if rcue.ue_1
        }
        self.decisions_ues.update(
            {
                rcue.ue_2.id: DecisionsProposeesUE(
                    etud, self.formsemestre_pair, rcue, True, self.inscription_etat
                )
                for rcue in self.rcue_by_niveau.values()
                if rcue.ue_2
            }
        )
        self.decisions_rcue_by_niveau = self._compute_decisions_niveaux()
        "les décisions rcue associées aux niveau_id"
        self.dec_rcue_by_ue = self._dec_rcue_by_ue()
        "{ ue_id : DecisionsProposeesRCUE } pour toutes les UE associées à un niveau"
        self.nb_competences = len(self.niveaux_competences)
        "le nombre de niveaux de compétences à valider cette année"
        rcues_avec_niveau = [d.rcue for d in self.decisions_rcue_by_niveau.values()]
        self.nb_validables = len(
            [rcue for rcue in rcues_avec_niveau if rcue.est_validable()]
        )
        "le nombre de comp. validables (éventuellement par compensation)"
        self.nb_rcue_valides = len(
            [rcue for rcue in rcues_avec_niveau if rcue.code_valide()]
        )
        "le nombre de niveaux validés (déc. jury prise)"
        self.nb_rcues_under_8 = len(
            [rcue for rcue in rcues_avec_niveau if not rcue.est_suffisant()]
        )
        "le nb de comp. sous la barre de 8/20"
        # année ADM si toutes RCUE validées (sinon PASD) et non DEM ou DEF
        self.admis = (self.nb_validables == self.nb_competences) and (
            self.inscription_etat == scu.INSCRIT
        )
        "vrai si l'année est réussie, tous niveaux validables ou validés par le jury"
        self.valide_moitie_rcue = self.nb_validables > (self.nb_competences // 2)
        "Vrai si plus de la moitié des RCUE validables"
        self.passage_de_droit = self.valide_moitie_rcue and (self.nb_rcues_under_8 == 0)
        "Vrai si peut passer dans l'année BUT suivante: plus de la moitié validables et tous > 8"
        explanation = ""
        # Cas particulier du passage en BUT 3: nécessité d'avoir validé toutes les UEs du BUT 1.
        if self.passage_de_droit and self.annee_but == 2:
            inscription = formsemestre.etuds_inscriptions.get(etud.id)
            if not inscription or inscription.etat != scu.INSCRIT:
                # pas inscrit dans le semestre courant ???
                self.passage_de_droit = False
            else:
                self.passage_de_droit, explanation = self.passage_de_droit_en_but3()

        # Enfin calcule les codes des UEs:
        for dec_ue in self.decisions_ues.values():
            dec_ue.compute_codes()

        # Reste à attribuer ADM, ADJ, PASD, PAS1NCI, RED, NAR
        plural = self.nb_validables > 1
        explanation += f"""{self.nb_validables} niveau{"x" if plural else ""} validable{
                "s" if plural else ""} de droit sur {self.nb_competences}"""
        if self.admis:
            self.codes = [sco_codes.ADM] + self.codes
        # elif not self.jury_annuel:
        #    self.codes = []  # pas de décision annuelle sur semestres impairs
        elif self.inscription_etat != scu.INSCRIT:
            self.codes = [
                (
                    sco_codes.DEM
                    if self.inscription_etat == scu.DEMISSION
                    else sco_codes.DEF
                ),
                # propose aussi d'autres codes, au cas où...
                (
                    sco_codes.DEM
                    if self.inscription_etat != scu.DEMISSION
                    else sco_codes.DEF
                ),
                sco_codes.ABAN,
                sco_codes.ABL,
                sco_codes.EXCLU,
            ]
            explanation = ""
        elif self.passage_de_droit:
            self.codes = [sco_codes.PASD, sco_codes.ADJ] + self.codes
        elif self.valide_moitie_rcue:  # mais au moins 1 rcue insuffisante
            self.codes = [
                sco_codes.RED,
                sco_codes.NAR,
                sco_codes.PAS1NCI,
                sco_codes.ADJ,
            ] + self.codes
            explanation += f" et {self.nb_rcues_under_8} < 8"
        else:  # autres cas: non admis, non passage, non dem, pas la moitié des rcue:
            if formsemestre.semestre_id % 2 and self.formsemestre_pair is None:
                # Si jury sur un seul semestre impair, ne  propose pas redoublement
                # et efface décision éventuellement existante
                codes = [None]
            else:
                codes = []
            self.codes = (
                codes
                + [
                    sco_codes.RED,
                    sco_codes.NAR,
                    sco_codes.PAS1NCI,
                    sco_codes.ADJ,
                    sco_codes.PASD,  # voir #488 (discutable, conventions locales)
                ]
                + self.codes
            )
            explanation += f""" et {self.nb_rcues_under_8
                                    } niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8"""

        # Si l'un des semestres est extérieur, propose ADM
        if (
            self.formsemestre_impair and self.formsemestre_impair.modalite == "EXT"
        ) or (self.formsemestre_pair and self.formsemestre_pair.modalite == "EXT"):
            self.codes.insert(0, sco_codes.ADM)
        # Si validée par niveau supérieur:
        if self.code_valide == sco_codes.ADSUP:
            self.codes.insert(0, sco_codes.ADSUP)
        self.explanation = f'<div class="deca-expl">{explanation}</div>'
        messages = self.descr_pb_coherence()
        if messages:
            self.explanation += (
                '<div class="warning warning-info">'
                + '</div><div class="warning warning-info">'.join(messages)
                + "</div>"
            )
        self.codes = [self.codes[0]] + sorted((c or "") for c in self.codes[1:])

    def passage_de_droit_en_but3(self) -> tuple[bool, str]:
        """Vérifie si les conditions supplémentaires de passage BUT2 vers BUT3 sont satisfaites"""
        cursus: EtudCursusBUT = EtudCursusBUT(self.etud, self.formsemestre.formation)
        niveaux_but1 = cursus.niveaux_by_annee[1]

        niveaux_but1_non_valides = []
        for niveau in niveaux_but1:
            ok = False
            validation_par_annee = cursus.validation_par_competence_et_annee.get(
                niveau.competence_id
            )
            if validation_par_annee:
                validation_niveau = validation_par_annee.get("BUT1")
                if validation_niveau and validation_niveau.code in CODES_RCUE_VALIDES:
                    ok = True
            if not ok:
                niveaux_but1_non_valides.append(niveau)

        # Les niveaux de BUT1 manquants passent-ils en ADSUP ?
        # en vertu de l'article 4.3,
        # "La validation des deux UE du niveau d’une compétence emporte la validation de
        # l’ensemble des UE du niveau inférieur de cette même compétence."
        explanation = ""
        ok = True
        for niveau_but1 in niveaux_but1_non_valides:
            niveau_but2 = niveau_but1.competence.niveaux.filter_by(annee="BUT2").first()
            if niveau_but2:
                rcue = self.rcue_by_niveau.get(niveau_but2.id)
                if (rcue is None) or (
                    not rcue.est_validable() and not rcue.code_valide()
                ):
                    # le RCUE de BUT2 n'est ni validable (avec les notes en cours) ni déjà validé
                    ok = False
                    explanation += (
                        f"Compétence {niveau_but1} de BUT 1 non validée.<br> "
                    )
                else:
                    explanation += (
                        f"Compétence {niveau_but1} de BUT 1 validée par ce BUT2.<br> "
                    )
            else:
                ok = False
                explanation += f"""Compétence {
                    niveau_but1} de BUT 1 non validée et non existante en BUT2.<br> """

        return ok, explanation

    # WIP TODO XXX def get_moyenne_annuelle(self)

    def infos(self) -> str:
        """informations, for debugging purpose."""
        text = f"""<b>DecisionsProposeesAnnee</b>
        <ul>
        <li>Étudiant: {self.etud.html_link_fiche()}</li>
        """
        for formsemestre, title in (
            (self.formsemestre_impair, "formsemestre_impair"),
            (self.formsemestre_pair, "formsemestre_pair"),
        ):
            text += f"<li>{title}:"
            if formsemestre is not None:
                text += f"""
                <a href="{url_for("notes.formsemestre_status",
                    scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
                }">{html.escape(str(formsemestre))}</a>
                    <ul>
                    <li>Formation: <a href="{url_for('notes.ue_table',
                        scodoc_dept=g.scodoc_dept,
                        semestre_idx=formsemestre.semestre_id,
                        formation_id=formsemestre.formation.id)}">
                        {formsemestre.formation.html()} ({
                            formsemestre.formation.id})</a>
                    </li>
                    </ul>
                """
            else:
                text += " aucun."
            text += "</li>"

        text += f"""
            <li>RCUEs: {html.escape(str(self.rcue_by_niveau))}</li>
            <li>nb_competences: {getattr(self, "nb_competences", "-")}</li>
            <li>nb_validables: {getattr(self, "nb_validables", "-")}</li>
            <li>codes: {self.codes}</li>
            <li>explanation: {self.explanation}</li>
            </ul>
            """
        return text

    def annee_scolaire(self) -> int:
        "L'année de début de l'année scolaire"
        formsemestre = self.formsemestre_impair or self.formsemestre_pair
        return formsemestre.annee_scolaire()

    def annee_scolaire_str(self) -> str:
        "L'année scolaire, eg '2021 - 2022'"
        formsemestre = self.formsemestre_impair or self.formsemestre_pair
        return formsemestre.annee_scolaire_str().replace(" ", "")

    def comp_formsemestres(
        self, formsemestre: FormSemestre
    ) -> tuple[FormSemestre, FormSemestre]:
        """Les deux formsemestres auquel est inscrit l'étudiant (ni DEM ni DEF)
        du niveau auquel appartient formsemestre.

        -> S_impair, S_pair (de la même année scolaire)

        Si l'origine est impair, S_impair est l'origine et S_pair est None
        Si l'origine est paire, S_pair est l'origine, et S_impair l'antérieur
        suivi par cet étudiant (ou None).

        Note: si l'option "block_moyennes" est activée, ne prend pas en compte le semestre.
        """
        if not formsemestre.formation.is_apc():  # garde fou
            return None, None

        if formsemestre.semestre_id % 2:
            idx_autre = formsemestre.semestre_id + 1  # impair, autre = suivant
        else:
            idx_autre = formsemestre.semestre_id - 1  # pair: autre = précédent

        # Cherche l'autre semestre de la même année scolaire:
        autre_formsemestre = None
        for inscr in self.etud.formsemestre_inscriptions:
            if (
                (inscr.etat == scu.INSCRIT)
                and
                # Même spécialité BUT (tolère ainsi des variantes de formation)
                (
                    inscr.formsemestre.formation.referentiel_competence
                    == formsemestre.formation.referentiel_competence
                )
                # Non bloqué
                and not inscr.formsemestre.block_moyennes
                # L'autre semestre
                and (inscr.formsemestre.semestre_id == idx_autre)
                # de la même année scolaire
                and inscr.formsemestre.annee_scolaire() == formsemestre.annee_scolaire()
            ):
                autre_formsemestre = inscr.formsemestre
                break
        # autre_formsemestre peut être None
        if formsemestre.semestre_id % 2:
            return formsemestre, autre_formsemestre
        else:
            return autre_formsemestre, formsemestre

    def get_decisions_rcues_annee(self) -> list["DecisionsProposeesRCUE"]:
        "Liste des DecisionsProposeesRCUE de l'année, tirée par numéro d'UE"
        return self.decisions_rcue_by_niveau.values()

    def _compute_rcues_annee(self) -> dict[int, RegroupementCoherentUE]:
        "calcule tous les RCUEs: { niveau_id : rcue }"
        semestre_id_impair = ((self.formsemestre.semestre_id - 1) // 2) * 2 + 1
        return {
            niveau.id: RegroupementCoherentUE(
                self.etud,
                niveau,
                self.res_pair,
                self.res_impair,
                semestre_id_impair,
                self.cur_ues_pair,
                self.cur_ues_impair,
            )
            for niveau in self.niveaux_competences
        }

    def _compute_decisions_niveaux(self) -> dict[int, "DecisionsProposeesRCUE"]:
        """Pour chaque niveau de compétence de cette année, construit
        le DecisionsProposeesRCUE à partir du rcue déjà calculé.
        Appelé à la construction du deca, donc avant décisions manuelles.
        Return: { niveau_id : DecisionsProposeesRCUE }
        """
        # Ordonne par numéro d'UE
        niv_rcue = sorted(
            self.rcue_by_niveau.items(),
            key=lambda x: (
                x[1].ue_1.numero if x[1].ue_1 else x[1].ue_2.numero if x[1].ue_2 else 0
            ),
        )
        return {
            niveau_id: DecisionsProposeesRCUE(self, rcue, self.inscription_etat)
            for (niveau_id, rcue) in niv_rcue
        }

    def _dec_rcue_by_ue(self) -> dict[int, "DecisionsProposeesRCUE"]:
        """construit dict { ue_id : DecisionsProposeesRCUE }
        à partir de self.decisions_rcue_by_niveau"""
        d = {}
        for dec_rcue in self.decisions_rcue_by_niveau.values():
            if dec_rcue.rcue.ue_1:
                d[dec_rcue.rcue.ue_1.id] = dec_rcue
            if dec_rcue.rcue.ue_2:
                d[dec_rcue.rcue.ue_2.id] = dec_rcue
        return d

    def ects_annee(self) -> float:
        "ECTS validés dans l'année BUT courante"
        return sum([dec_ue.ects_acquis() for dec_ue in self.decisions_ues.values()])

    def next_semestre_ids(self, code: str) -> set[int]:
        """Les indices des semestres dans lequels l'étudiant est autorisé
        à poursuivre après le semestre courant.
        code: code jury sur année BUT
        """
        # La poursuite d'études dans un semestre pair d'une même année
        # est de droit pour tout étudiant.
        # Pas de redoublements directs de S_impair vers S_impair
        # (pourront être traités manuellement)
        if (
            self.formsemestre.semestre_id % 2
        ) and self.formsemestre.semestre_id < sco_codes.CursusBUT.NB_SEM:
            return {self.formsemestre.semestre_id + 1}
        # La poursuite d'études dans un semestre impair est possible si
        # et seulement si l'étudiant a obtenu :
        #  - la moyenne à plus de la moitié des regroupements cohérents d'UE ;
        #  - et une moyenne égale ou supérieure à 8 sur 20 à chaque RCUE.
        #
        # La condition a paru trop stricte à de nombreux collègues.
        # ScoDoc ne contraint donc pas à la respecter strictement.
        # Si le code est dans BUT_CODES_PASSAGE (ADM, ADJ, PASD, PAS1NCI, ATJ),
        # autorise à passer dans le semestre suivant
        ids = set()
        if (
            self.jury_annuel
            and code in sco_codes.BUT_CODES_PASSAGE
            and self.formsemestre.semestre_id < sco_codes.CursusBUT.NB_SEM
        ):
            ids.add(self.formsemestre.semestre_id + 1)

        if code == RED:
            ids.add(
                self.formsemestre.semestre_id - (self.formsemestre.semestre_id + 1) % 2
            )

        return ids

    def record_form(self, form: dict):
        """Enregistre les codes de jury en base
        à partir d'un dict représentant le formulaire jury BUT:
        form dict:
        - 'code_ue_1896' : 'AJ'  code pour l'UE id 1896
        - 'code_rcue_6" : 'ADM'  code pour le RCUE du niveau 6
        - 'code_annee' : 'ADM'   code pour l'année

        Si les code_rcue et le code_annee ne sont pas fournis,
        et qu'il n'y en a pas déjà, enregistre ceux par défaut.

        Si le code_annee est None, efface le code déjà enregistré.
        """
        log("jury_but.DecisionsProposeesAnnee.record_form")
        code_annee = self.codes[0]  # si pas dans le form, valeur par defaut
        codes_rcues = []  # [ (dec_rcue, code), ... ]
        codes_ues = []  #  [ (dec_ue, code), ... ]
        for key in form:
            code = form[key]
            # Codes d'UE
            m = re.match(r"^code_ue_(\d+)$", key)
            if m:
                ue_id = int(m.group(1))
                dec_ue = self.decisions_ues.get(ue_id)
                if not dec_ue:
                    raise ScoValueError(f"UE invalide ue_id={ue_id}")
                codes_ues.append((dec_ue, code))
            else:
                # Codes de RCUE
                m = re.match(r"^code_rcue_(\d+)$", key)
                if m:
                    niveau_id = int(m.group(1))
                    dec_rcue = self.decisions_rcue_by_niveau.get(niveau_id)
                    if not dec_rcue:
                        raise ScoValueError(f"RCUE invalide niveau_id={niveau_id}")
                    codes_rcues.append((dec_rcue, code))
                elif key == "code_annee":
                    # Code annuel
                    code_annee = code

        with sco_cache.DeferredSemCacheManager():
            # Enregistre les codes, dans l'ordre UE, RCUE, Année
            for dec_ue, code in codes_ues:
                dec_ue.record(code)
            for dec_rcue, code in codes_rcues:
                dec_rcue.record(code)
            self.record(code_annee)
            self.record_autorisation_inscription(code_annee)
            self.record_all()
            self.recorded = True

        db.session.commit()

    def record(self, code: str, mark_recorded: bool = True) -> bool:
        """Enregistre le code de l'année, et au besoin l'autorisation d'inscription.
        Si l'étudiant est DEM ou DEF, ne fait rien.
        Si le code est None, efface le code déjà enregistré.
        Si mark_recorded est vrai, positionne self.recorded
        """
        if self.inscription_etat != scu.INSCRIT:
            return False
        if code and not code in self.codes:
            raise ScoValueError(
                f"code annee <tt>{html.escape(code)}</tt> invalide pour formsemestre {html.escape(self.formsemestre)}"
            )

        if code != self.code_valide:
            # Enregistrement du code annuel BUT
            if code is None:
                if self.validation:
                    db.session.delete(self.validation)
                    self.validation = None
                    db.session.commit()
            else:
                if self.validation is None:
                    self.validation = ApcValidationAnnee(
                        etudid=self.etud.id,
                        formsemestre=self.formsemestre_impair,
                        referentiel_competence_id=self.formsemestre.formation.referentiel_competence_id,
                        ordre=self.annee_but,
                        annee_scolaire=self.annee_scolaire(),
                        code=code,
                    )
                else:  # Update validation année BUT
                    assert self.validation.etudid == self.etud.id
                    self.validation.formsemestre = self.formsemestre_impair
                    self.validation.formation_id = self.formsemestre.formation_id
                    self.validation.ordre = self.annee_but
                    self.validation.annee_scolaire = self.annee_scolaire()
                    self.validation.code = code
                    self.validation.date = datetime.now()

                db.session.add(self.validation)
                db.session.commit()
                log(f"Recording {self}: {code}")
                Scolog.logdb(
                    method="jury_but",
                    etudid=self.etud.id,
                    msg=f"Validation année BUT{self.annee_but}: {code}",
                )
            if mark_recorded:
                self.recorded = True
        self.invalidate_formsemestre_cache()
        return True

    def record_autorisation_inscription(self, code: str):
        """Autorisation d'inscription dans semestre suivant.
        code: code jury sur année BUT
        """
        if self.autorisations_recorded:
            return
        if self.inscription_etat != scu.INSCRIT:
            # les dem et DEF ne continuent jamais
            return
        ScolarAutorisationInscription.delete_autorisation_etud(
            etudid=self.etud.id,
            origin_formsemestre_id=self.formsemestre.id,
        )
        for next_semestre_id in self.next_semestre_ids(code):
            ScolarAutorisationInscription.autorise_etud(
                self.etud.id,
                self.formsemestre.formation.formation_code,
                self.formsemestre.id,
                next_semestre_id,
            )
            self.autorisations_recorded = True

    def invalidate_formsemestre_cache(self):
        "invalide le résultats des deux formsemestres"
        if self.formsemestre_impair is not None:
            sco_cache.invalidate_formsemestre(
                formsemestre_id=self.formsemestre_impair.id
            )
        if self.formsemestre_pair is not None:
            sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id)

    def _get_current_res(self) -> ResultatsSemestreBUT:
        "Les res. du semestre d'origine du deca"
        return (
            self.res_pair
            if self.formsemestre_pair
            and (self.formsemestre.id == self.formsemestre_pair.id)
            else self.res_impair
        )

    def has_notes_en_attente(self) -> bool:
        "Vrai si l'étudiant a au moins une note en attente dans le semestre origine de ce deca"
        res = self._get_current_res()
        return res and self.etud.id in res.get_etudids_attente()

    def get_modimpls_attente(self) -> list[ModuleImpl]:
        "Liste des ModuleImpl dans lesquels l'étudiant à au moins une note en ATTente"
        res = self._get_current_res()
        modimpls_results = [
            modimpl_result
            for modimpl_result in res.modimpls_results.values()
            if self.etud.id in modimpl_result.etudids_attente
        ]
        modimpls = [
            db.session.get(ModuleImpl, mr.moduleimpl_id) for mr in modimpls_results
        ]
        return sorted(modimpls, key=lambda mi: (mi.module.numero, mi.module.code))

    def record_all(self, only_validantes: bool = False) -> bool:
        """Enregistre les codes qui n'ont pas été spécifiés par le formulaire,
        et sont donc en mode "automatique".
        - Si "à cheval", ne modifie pas les codes UE de l'année scolaire précédente.
        - Pour les RCUE: n'enregistre que si la nouvelle décision est plus favorable que l'ancienne.

        Si only_validantes, n'enregistre que des décisions "validantes" de droit: ADM ou CMP,
        et seulement si l'étudiant n'a pas de notes en ATTente.

        Return: True si au moins un code modifié et enregistré.
        """
        modif = False
        if only_validantes:
            if self.has_notes_en_attente():
                # notes en attente dans formsemestre origine
                return False
            if Evaluation.get_evaluations_blocked_for_etud(
                self.formsemestre, self.etud
            ):
                # évaluation(s) qui seront débloquées dans le futur
                return False

        # Toujours valider dans l'ordre UE, RCUE, Année
        annee_scolaire = self.formsemestre.annee_scolaire()
        # UEs
        for dec_ue in self.decisions_ues.values():
            if (
                dec_ue.formsemestre
                and (not dec_ue.recorded)
                and dec_ue.formsemestre.annee_scolaire() == annee_scolaire
            ):
                # rappel: le code par défaut est en tête
                code = dec_ue.codes[0] if dec_ue.codes else None
                if (not only_validantes) or code in sco_codes.CODES_UE_VALIDES_DE_DROIT:
                    # enregistre le code jury
                    modif |= dec_ue.record(code)
        # RCUE :
        for dec_rcue in self.decisions_rcue_by_niveau.values():
            code = dec_rcue.codes[0] if dec_rcue.codes else None
            if (
                (not dec_rcue.recorded)
                and (  # enregistre seulement si pas déjà validé "mieux"
                    (not dec_rcue.validation)
                    or BUT_CODES_ORDER.get(dec_rcue.validation.code, 0)
                    < BUT_CODES_ORDER.get(code, 0)
                )
                and (  # décision validante de droit ?
                    (
                        (not only_validantes)
                        or code in sco_codes.CODES_RCUE_VALIDES_DE_DROIT
                    )
                )
            ):
                modif |= dec_rcue.record(code)
        # Année:
        if not self.recorded:
            # rappel: le code par défaut est en tête
            code = self.codes[0] if self.codes else None
            if (
                not only_validantes
            ) or code in sco_codes.CODES_ANNEE_BUT_VALIDES_DE_DROIT:
                modif |= self.record(code)
            self.record_autorisation_inscription(code)
        return modif

    def erase(self, only_one_sem=False):
        """Efface les décisions de jury de cet étudiant
        pour cette année: décisions d'UE, de RCUE, d'année,
        et autorisations d'inscription émises.
        Efface même si étudiant DEM ou DEF.
        Si only_one_sem, n'efface que les décisions UE et les
        autorisations de passage du semestre d'origine du deca.

        Dans tous les cas, efface les validations de l'année en cours.
        (commite la session.)
        """
        if only_one_sem:
            # N'efface que les autorisations venant de ce semestre,
            # et les validations de ses UEs
            ScolarAutorisationInscription.delete_autorisation_etud(
                self.etud.id, self.formsemestre.id
            )
            for dec_ue in self.decisions_ues.values():
                if (
                    dec_ue
                    and dec_ue.formsemestre
                    and self.formsemestre
                    and dec_ue.formsemestre.id == self.formsemestre.id
                ):
                    dec_ue.erase()
        else:
            for dec_ue in self.decisions_ues.values():
                if dec_ue:
                    dec_ue.erase()

            if self.formsemestre_impair:
                ScolarAutorisationInscription.delete_autorisation_etud(
                    self.etud.id, self.formsemestre_impair.id
                )
            if self.formsemestre_pair:
                ScolarAutorisationInscription.delete_autorisation_etud(
                    self.etud.id, self.formsemestre_pair.id
                )
        # Efface les RCUEs
        for dec_rcue in self.decisions_rcue_by_niveau.values():
            dec_rcue.erase()

        # Efface les validations concernant l'année BUT
        # de ce semestre
        validations = ApcValidationAnnee.query.filter_by(
            etudid=self.etud.id,
            ordre=self.annee_but,
            referentiel_competence_id=self.formsemestre.formation.referentiel_competence_id,
        )
        for validation in validations:
            db.session.delete(validation)
            Scolog.logdb(
                "jury_but",
                etudid=self.etud.id,
                msg=f"Validation année BUT{self.annee_but}: effacée",
            )

        # Efface éventuelles validations de semestre
        # (en principe inutilisées en BUT)
        # et autres UEs (en cas de changement d'architecture de formation depuis le jury ?)
        #
        for validation in ScolarFormSemestreValidation.query.filter_by(
            etudid=self.etud.id, formsemestre_id=self.formsemestre.id
        ):
            db.session.delete(validation)

        db.session.commit()
        self.invalidate_formsemestre_cache()

    def get_autorisations_passage(self) -> list[int]:
        """Liste des indices de semestres auxquels on est autorisé à
        s'inscrire depuis le semestre courant.
        """
        return sorted(
            [
                a.semestre_id
                for a in ScolarAutorisationInscription.query.filter_by(
                    etudid=self.etud.id,
                    origin_formsemestre_id=self.formsemestre.id,
                )
            ]
        )

    def descr_niveaux_validation(self, line_sep: str = "\n") -> str:
        """Description textuelle des niveaux validés (enregistrés)
        pour PV jurys
        """
        validations = [
            dec_rcue.descr_validation()
            for dec_rcue in self.decisions_rcue_by_niveau.values()
        ]
        return line_sep.join(v for v in validations if v)

    def descr_ues_validation(self, line_sep: str = "\n") -> str:
        """Description textuelle des UE validées (enregistrés)
        pour PV jurys
        """
        validations = []
        for res in (self.res_impair, self.res_pair):
            if res:
                dec_ues = [
                    self.decisions_ues[ue.id]
                    for ue in res.ues
                    if ue.type == UE_STANDARD and ue.id in self.decisions_ues
                ]
                valids = [dec_ue.descr_validation() for dec_ue in dec_ues]
                validations.append(", ".join(v for v in valids if v))
        return line_sep.join(validations)

    def descr_pb_coherence(self) -> list[str]:
        """Description d'éventuels problèmes de cohérence entre
        les décisions *enregistrées* d'UE et de RCUE.
        Note: en principe, la cohérence RCUE/UE est assurée au moment de
        l'enregistrement (record).
        Mais la base peut avoir été modifiée par d'autres voies.
        """
        messages = []
        for dec_rcue in self.decisions_rcue_by_niveau.values():
            if dec_rcue.code_valide in CODES_RCUE_VALIDES:
                for ue in (dec_rcue.rcue.ue_1, dec_rcue.rcue.ue_2):
                    if ue:
                        dec_ue = self.decisions_ues.get(ue.id)
                        if dec_ue:
                            if dec_ue.code_valide not in CODES_UE_VALIDES:
                                if (
                                    dec_ue.ue_status
                                    and dec_ue.ue_status["is_capitalized"]
                                ):
                                    messages.append(
                                        f"Information: l'UE {ue.acronyme} capitalisée est utilisée pour un RCUE cette année"
                                    )
                                else:
                                    messages.append(
                                        f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est (probablement une validation antérieure)"
                                    )
                        else:
                            messages.append(
                                f"L'UE {ue.acronyme} n'a pas décision (???)"
                            )
                        # Voyons si on est dispensé de cette ue ?
                        res = self.res_impair if ue.semestre_idx % 2 else self.res_pair
                        if res and (self.etud.id, ue.id) in res.dispense_ues:
                            messages.append(f"Pas (ré)inscrit à l'UE {ue.acronyme}")
        return messages

    def valide_diplome(self) -> bool:
        "Vrai si l'étudiant à validé son diplôme"
        return False  # TODO XXX


def list_ue_parcour_etud(
    formsemestre: FormSemestre,
    etud: Identite,
    parcour: ApcParcours,
    res: ResultatsSemestreBUT,
) -> list[UniteEns]:
    """Liste des UEs suivies ce semestre (sans les UE "dispensées")"""

    if parcour is None:
        # pas de parcour: prend toutes les UEs (non bonus)
        ues = [ue for ue in res.etud_ues(etud.id) if ue.type == UE_STANDARD]
        ues.sort(key=lambda u: u.numero)
    else:
        ues = (
            formsemestre.formation.query_ues_parcour(parcour)
            .filter(UniteEns.semestre_idx == formsemestre.semestre_id)
            .order_by(UniteEns.numero)
            .all()
        )
    return [ue for ue in ues if (etud.id, ue.id) not in res.dispense_ues]


class DecisionsProposeesRCUE(DecisionsProposees):
    """Liste des codes de décisions que l'on peut proposer pour
    le RCUE de cet étudiant dans cette année.

    ADM, CMP, ADJ, AJ, RAT, DEF, ABAN
    """

    codes_communs = [
        sco_codes.ADJ,
        sco_codes.ATJ,
        sco_codes.RAT,
        sco_codes.DEF,
        sco_codes.ABAN,
    ]

    def __init__(
        self,
        dec_prop_annee: DecisionsProposeesAnnee,
        rcue: RegroupementCoherentUE,
        inscription_etat: str = scu.INSCRIT,
    ):
        super().__init__(etud=dec_prop_annee.etud)
        self.deca = dec_prop_annee
        self.referentiel_competence_id = (
            self.deca.formsemestre.formation.referentiel_competence_id
        )
        self.rcue = rcue
        if rcue is None:  # RCUE non dispo, eg un seul semestre
            self.codes = []
            return
        self.inscription_etat = inscription_etat
        "inscription: I, DEM, DEF"
        self.parcour = dec_prop_annee.parcour
        if inscription_etat != scu.INSCRIT:
            self.validation = None  # cache toute validation
            self.explanation = "non incrit (dem. ou déf.)"
            self.codes = [
                sco_codes.DEM if inscription_etat == scu.DEMISSION else sco_codes.DEF
            ]
            return
        self.validation: ApcValidationRCUE = rcue.query_validations().first()
        if self.validation is not None:
            self.code_valide = self.validation.code
        if rcue.est_compensable():
            self.codes.insert(0, sco_codes.CMP)
            # les interprétations varient, on autorise aussi ADM:
            self.codes.insert(1, sco_codes.ADM)
        elif rcue.est_validable():
            self.codes.insert(0, sco_codes.ADM)
        else:
            self.codes.insert(0, sco_codes.AJ)
        # Si au moins l'un des semestres est extérieur, propose ADM au cas où
        if (
            dec_prop_annee.formsemestre_impair
            and dec_prop_annee.formsemestre_impair.modalite == "EXT"
        ) or (
            dec_prop_annee.formsemestre_pair
            and dec_prop_annee.formsemestre_pair.modalite == "EXT"
        ):
            self.codes.insert(0, sco_codes.ADM)
        # S'il y a une décision enregistrée: si elle est plus favorable que celle que l'on
        # proposerait, la place en tête.
        # Sinon, la place en seconde place
        if self.code_valide and self.code_valide != self.codes[0]:
            code_default = self.codes[0]
            if self.code_valide in self.codes:
                self.codes.remove(self.code_valide)
            if sco_codes.BUT_CODES_ORDER.get(
                self.code_valide, 0
            ) > sco_codes.BUT_CODES_ORDER.get(code_default, 0):
                self.codes.insert(0, self.code_valide)
            else:
                self.codes.insert(1, self.code_valide)
        self.codes = [self.codes[0]] + sorted(self.codes[1:])

    def __repr__(self) -> str:
        return f"""<{self.__class__.__name__} rcue={self.rcue} valid={self.code_valide
        } codes={self.codes} explanation={self.explanation}"""

    def record(self, code: str) -> bool:
        """Enregistre le code RCUE.
        Note:
            - si le RCUE est ADJ, les UE non validées sont passées à ADJ
        XXX on pourra imposer ici d'autres règles de cohérence
        """
        if self.rcue is None:
            return False  # pas de RCUE a enregistrer
        if not (self.rcue.ue_1 and self.rcue.ue_2):
            return False  # on n'a pas les deux UEs
        if self.inscription_etat != scu.INSCRIT:
            return False
        if code and not code in self.codes:
            raise ScoValueError(
                f"code RCUE invalide pour {self.rcue}: {html.escape(code)}"
            )
        if code == self.code_valide:
            self.recorded = True
            return False  # no change
        parcours_id = self.parcour.id if self.parcour is not None else None
        if self.validation:
            db.session.delete(self.validation)
            db.session.commit()
        if code is None:
            self.validation = None
        else:
            self.validation = ApcValidationRCUE(
                etudid=self.etud.id,
                formsemestre_id=self.deca.formsemestre.id,  # origine
                ue1_id=self.rcue.ue_1.id,
                ue2_id=self.rcue.ue_2.id,
                parcours_id=parcours_id,
                code=code,
            )
            db.session.add(self.validation)
            db.session.commit()
            Scolog.logdb(
                method="jury_but",
                etudid=self.etud.id,
                msg=f"Validation {self.rcue}: {code}",
                commit=True,
            )
            log(f"rcue.record {self}: {code}")

            # Modifie au besoin les codes d'UE
            if code == "ADJ":
                deca = self.deca
                for ue_id in (self.rcue.ue_1.id, self.rcue.ue_2.id):
                    dec_ue = deca.decisions_ues.get(ue_id)
                    if dec_ue and dec_ue.code_valide not in CODES_UE_VALIDES:
                        log(f"rcue.record: force ADJR sur {dec_ue}")
                        flash(
                            f"""UEs du RCUE "{
                                dec_ue.ue.niveau_competence.competence.titre
                            }" passées en ADJR"""
                        )
                        dec_ue.record(sco_codes.ADJR)

            # Valide les niveaux inférieurs de la compétence (code ADSUP)
            if code in CODES_RCUE_VALIDES:
                self.valide_niveau_inferieur()

        if self.rcue.res_impair is not None:
            sco_cache.invalidate_formsemestre(
                formsemestre_id=self.rcue.res_impair.formsemestre.id
            )
        if self.rcue.res_pair is not None:
            sco_cache.invalidate_formsemestre(
                formsemestre_id=self.rcue.res_pair.formsemestre.id
            )
        self.code_valide = code  # mise à jour état
        self.recorded = True
        return True

    def erase(self):
        """Efface la décision de jury de cet étudiant pour cet RCUE"""
        # par prudence, on requete toutes les validations, en cas de doublons
        validations = self.rcue.query_validations()
        for validation in validations:
            log(f"DecisionsProposeesRCUE: deleting {validation}")
            db.session.delete(validation)
        db.session.flush()

    def descr_validation(self) -> str:
        """Description validation niveau enregistrée, pour PV jury.
        Si le niveau est validé, donne son acronyme, sinon chaine vide.
        """
        if self.code_valide in sco_codes.CODES_RCUE_VALIDES:
            if (
                self.rcue and self.rcue.ue_1 and self.rcue.ue_1.niveau_competence
            ):  # prudence !
                niveau_titre = self.rcue.ue_1.niveau_competence.competence.titre or ""
                ordre = self.rcue.ue_1.niveau_competence.ordre
            else:
                return "?"  # oups ?
            return f"{niveau_titre}-{ordre}"
        return ""

    def valide_niveau_inferieur(self) -> None:
        """Appelé juste après la validation d'un RCUE.
        *La validation des deux UE du niveau d'une compétence emporte la validation de
        l'ensemble des UEs du niveau inférieur de cette même compétence.*
        """
        if not self.rcue:
            return
        competence: ApcCompetence = self.rcue.niveau.competence
        ordre_inferieur = self.rcue.niveau.ordre - 1
        if ordre_inferieur < 1:
            return  # pas de niveau inferieur

        # --- Si le RCUE inférieur est déjà validé, ne fait rien
        validations_rcue = (
            ApcValidationRCUE.query.filter_by(etudid=self.etud.id)
            .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
            .join(ApcNiveau)
            .filter_by(ordre=ordre_inferieur)
            .join(ApcCompetence)
            .filter_by(id=competence.id)
            .all()
        )
        if [v for v in validations_rcue if code_rcue_validant(v.code)]:
            return  # déjà validé

        # --- Validations des UEs du niveau inférieur
        self.valide_ue_inferieures(
            self.rcue.semestre_id_impair, ordre_inferieur, competence
        )
        self.valide_ue_inferieures(
            self.rcue.semestre_id_pair, ordre_inferieur, competence
        )
        # --- Valide le RCUE inférieur
        if validations_rcue:
            # Met à jour validation existante
            validation_rcue = validations_rcue[0]
            validation_rcue.code = sco_codes.ADSUP
            validation_rcue.date = datetime.now()
            db.session.add(validation_rcue)
            db.session.commit()
            log(f"updating {validation_rcue}")
            if validation_rcue.formsemestre_id is not None:
                sco_cache.invalidate_formsemestre(
                    formsemestre_id=validation_rcue.formsemestre_id
                )
        else:
            # Crée nouvelle validation
            ue1 = self._get_ue_inferieure(
                self.rcue.semestre_id_impair, ordre_inferieur, competence
            )
            ue2 = self._get_ue_inferieure(
                self.rcue.semestre_id_pair, ordre_inferieur, competence
            )
            if ue1 and ue2:
                validation_rcue = ApcValidationRCUE(
                    etudid=self.etud.id,
                    ue1_id=ue1.id,
                    ue2_id=ue2.id,
                    code=sco_codes.ADSUP,
                    formsemestre_id=self.deca.formsemestre.id,  # origine
                )
                db.session.add(validation_rcue)
                db.session.commit()
                log(f"recording {validation_rcue}")

        self.valide_annee_inferieure()

    def _get_ue_inferieure(
        self, semestre_id: int, ordre_inferieur: int, competence: ApcCompetence
    ) -> UniteEns:
        "L'UE de la formation associée au semestre indiqué diu niveau de compétence"
        return (
            UniteEns.query.filter_by(
                formation_id=self.deca.formsemestre.formation_id,
                semestre_idx=semestre_id,
            )
            .join(ApcNiveau)
            .filter_by(ordre=ordre_inferieur)
            .join(ApcCompetence)
            .filter_by(id=competence.id)
        ).first()

    def valide_ue_inferieures(
        self, semestre_id: int, ordre_inferieur: int, competence: ApcCompetence
    ):
        """Au besoin, enregistre une validation d'UE ADSUP pour le niveau de compétence
        semestre_id : l'indice du semestre concerné (le pair ou l'impair du niveau courant)
        """
        semestre_id_inferieur = semestre_id - 2
        if semestre_id_inferieur < 1:
            return
        # Les validations d'UE existantes pour ce niveau inférieur ?
        validations_ues: list[ScolarFormSemestreValidation] = (
            ScolarFormSemestreValidation.query.filter_by(etudid=self.etud.id)
            .join(UniteEns)
            .filter_by(semestre_idx=semestre_id_inferieur)
            .join(ApcNiveau)
            .filter_by(ordre=ordre_inferieur)
            .join(ApcCompetence)
            .filter_by(id=competence.id)
        ).all()
        validations_ues_validantes = [
            validation
            for validation in validations_ues
            if sco_codes.code_ue_validant(validation.code)
        ]
        if not validations_ues_validantes:
            # Il faut créer une validation d'UE
            # cherche l'UE de notre formation associée à ce niveau
            # et warning si il n'y en a pas
            ue = self._get_ue_inferieure(
                semestre_id_inferieur, ordre_inferieur, competence
            )
            if not ue:
                # programme incomplet ou mal paramétré
                flash(
                    f"""Impossible de valider l'UE inférieure de la compétence {
                        competence.titre} (niveau {ordre_inferieur})
                    car elle n'existe pas dans la formation
                    """,
                    "warning",
                )
                log("valide_ue_inferieures: UE manquante dans la formation")
            else:
                validation_ue = ScolarFormSemestreValidation(
                    etudid=self.etud.id,
                    code=sco_codes.ADSUP,
                    ue_id=ue.id,
                    is_external=True,  # pas rattachée à un formsemestre
                )
                db.session.add(validation_ue)
                log(f"recording {validation_ue}")

    def valide_annee_inferieure(self) -> None:
        """Si tous les RCUEs de l'année inférieure sont validés, la valide"""
        # Indice de l'année inférieure:
        annee_courante = self.rcue.niveau.annee  # "BUT2"
        if not re.match(r"^BUT\d$", annee_courante):
            log("Warning: valide_annee_inferieure invalid annee_courante")
            return
        annee_inferieure = int(annee_courante[3]) - 1
        if annee_inferieure < 1:
            return
        # Garde-fou: Année déjà validée ?
        validations_annee: ApcValidationAnnee = ApcValidationAnnee.query.filter_by(
            etudid=self.etud.id,
            ordre=annee_inferieure,
            referentiel_competence_id=self.deca.formsemestre.formation.referentiel_competence_id,
        ).all()
        if len(validations_annee) > 1:
            log(
                f"warning: {len(validations_annee)} validations d'année\n{validations_annee}"
            )
        if [
            validation_annee
            for validation_annee in validations_annee
            if sco_codes.code_annee_validant(validation_annee.code)
        ]:
            return  # déja valide
        validation_annee = validations_annee[0] if validations_annee else None
        # Liste des niveaux à valider:
        # ici on sort l'artillerie lourde
        cursus: EtudCursusBUT = EtudCursusBUT(
            self.etud, self.deca.formsemestre.formation
        )
        niveaux_a_valider = cursus.niveaux_by_annee[annee_inferieure]
        # Pour chaque niveau, cherche validation RCUE
        validations_by_niveau = cursus.load_validation_by_niveau()
        ok = True
        for niveau in niveaux_a_valider:
            validation_niveau: ApcValidationRCUE = validations_by_niveau.get(niveau.id)
            if not validation_niveau or not sco_codes.code_rcue_validant(
                validation_niveau.code
            ):
                ok = False

        # Si tous OK, émet validation année
        if validation_annee:  # Modifie la validation antérieure (non validante)
            validation_annee.code = sco_codes.ADSUP
            validation_annee.date = datetime.now()
            log(f"updating {validation_annee}")
        else:
            validation_annee = ApcValidationAnnee(
                etudid=self.etud.id,
                ordre=annee_inferieure,
                referentiel_competence_id=self.deca.formsemestre.formation.referentiel_competence_id,
                code=sco_codes.ADSUP,
                # met cette validation sur l'année scolaire actuelle, pas la précédente
                annee_scolaire=self.deca.formsemestre.annee_scolaire(),
            )
            log(f"recording {validation_annee}")
        db.session.add(validation_annee)
        db.session.commit()


class DecisionsProposeesUE(DecisionsProposees):
    """Décisions de jury sur une UE du BUT

    Liste des codes de décisions que l'on peut proposer pour
    cette UE d'un étudiant dans un semestre.

    Si DEF ou DEM ou ABAN ou ABL sur année BUT: seulement DEF, DEM, ABAN, ABL

    si moy_ue > 10, ADM
    sinon si compensation dans RCUE: CMP
    sinon: ADJ, AJ

    et proposer toujours: RAT, DEF, ABAN, ADJR, DEM, UEBSL (codes_communs)


    Le DecisionsProposeesUE peut concerner une UE du formsemestre, ou une validation
    antérieure non éditable.
    """

    # Codes toujours proposés sauf si include_communs est faux:
    codes_communs = [
        sco_codes.RAT,
        sco_codes.DEF,
        sco_codes.ABAN,
        sco_codes.ADJR,
        sco_codes.ATJ,
        sco_codes.DEM,
        sco_codes.UEBSL,
    ]

    def __init__(
        self,
        etud: Identite,
        formsemestre: FormSemestre,
        rcue: RegroupementCoherentUE = None,
        paire: bool = False,
        inscription_etat: str = scu.INSCRIT,
    ):
        self.paire = paire
        self.formsemestre = formsemestre
        "Le formsemestre courant auquel appartient l'UE, ou None si validation antérieure"
        self.rcue: RegroupementCoherentUE = rcue
        "Le rcue auquel est rattaché cette UE, ou None"
        self.ue: UniteEns = rcue.ue_2 if paire else rcue.ue_1
        self.inscription_etat = inscription_etat
        # Une UE peut être validée plusieurs fois en cas de redoublement
        # (qu'elle soit capitalisée ou non)
        # mais ici on a restreint au formsemestre donc une seule (prend la première)

        self.cur_validation = (
            rcue.validation_ue_cur_pair if paire else rcue.validation_ue_cur_impair
        )
        "validation dans le formsemestre courant"
        autre_validation = (
            rcue.validation_ue_best_pair if paire else rcue.validation_ue_best_impair
        )
        "validation antérieure ou capitalisée"
        # la validation à afficher est celle "en cours", sauf si UE antérieure
        validation = self.cur_validation if self.formsemestre else autre_validation
        super().__init__(
            etud=etud,
            code_valide=validation.code if validation is not None else None,
        )
        self.validation = validation
        "validation dans le formsemestre courant ou à défaut celle enregistrée"

        # Editable ou pas ?
        # si ue courante, éditable.
        self.editable = self.cur_validation is not None
        res: ResultatsSemestreBUT = (
            self.rcue.res_pair if paire else self.rcue.res_impair
        )
        self.moy_ue = np.NaN
        self.moy_ue_with_cap = np.NaN
        self.ue_status = {}

        if self.ue.type != sco_codes.UE_STANDARD:
            self.explanation = "UE non standard, pas de décision de jury BUT"
            self.codes = []  # aucun code proposé
            return

        if res and res.get_etud_etat(etud.id) != scu.INSCRIT:
            self.validation = None  # cache toute validation
            self.explanation = "non inscrit (dem. ou déf.)"
            self.codes = [
                (
                    sco_codes.DEM
                    if res.get_etud_etat(etud.id) == scu.DEMISSION
                    else sco_codes.DEF
                )
            ]
            return

        # Moyenne de l'UE ?
        ue_status = self.rcue.ue_status_pair if paire else self.rcue.ue_status_impair
        if ue_status:
            self.moy_ue = ue_status["cur_moy_ue"]
            self.moy_ue_with_cap = ue_status["moy"]
        self.ue_status = ue_status
        self.codes = [self.codes[0]] + sorted(self.codes[1:])

    def __repr__(self) -> str:
        return f"""<{self.__class__.__name__} ue={self.ue.acronyme} valid={self.code_valide
        } codes={self.codes} explanation="{self.explanation}">"""

    def compute_codes(self):
        """Calcul des .codes attribuables et de l'explanation associée"""
        if self.inscription_etat != scu.INSCRIT:
            return
        # Si UE validée antérieure, on ne peut pas changer le code
        if not self.formsemestre:
            self.codes = [self.validation.code] if self.validation else []
            self.explanation = "enregistrée"
            return
        if (
            self.moy_ue > (sco_codes.CursusBUT.BARRE_MOY - sco_codes.NOTES_TOLERANCE)
        ) or self.formsemestre.modalite == "EXT":
            self.codes.insert(0, sco_codes.ADM)
            self.explanation = f"Moyenne >= {sco_codes.CursusBUT.BARRE_MOY}/20"
        elif (
            self.rcue
            and self.rcue.est_compensable()
            and self.ue_status
            and not self.ue_status["is_capitalized"]
        ):
            self.codes.insert(0, sco_codes.CMP)
            self.explanation = "compensable dans le RCUE"
        else:
            # Échec à valider cette UE
            self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes
            self.explanation = "notes insuffisantes"

    def record(self, code: str) -> bool:
        """Enregistre le code jury pour cette UE.
        Return: True si code enregistré (modifié)
        """
        if code and not code in self.codes:
            raise ScoValueError(
                f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}"
            )
        if code == self.code_valide:
            self.recorded = True
            return False  # no change
        self.erase()
        if code is None:
            self.validation = None
            Scolog.logdb(
                method="jury_but",
                etudid=self.etud.id,
                msg=f"Validation UE {self.ue.id} {self.ue.acronyme}: effacée",
                commit=True,
            )
        else:
            self.validation = ScolarFormSemestreValidation(
                etudid=self.etud.id,
                formsemestre_id=self.formsemestre.id,
                ue_id=self.ue.id,
                code=code,
                moy_ue=self.moy_ue,
            )
            db.session.add(self.validation)
            db.session.commit()
            Scolog.logdb(
                method="jury_but",
                etudid=self.etud.id,
                msg=f"Validation UE {self.ue.id} {self.ue.acronyme}({self.moy_ue}): {code}",
                commit=True,
            )
            log(f"DecisionsProposeesUE: recording {self.validation}")

        sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id)
        self.code_valide = code  # mise à jour
        self.recorded = True
        return True

    def erase(self):
        """Efface la décision de jury de cet étudiant pour cette UE"""
        # par prudence, on requete toutes les validations, en cas de doublons
        if not self.formsemestre:
            return  # antérieure, rien à effacer
        validations = ScolarFormSemestreValidation.query.filter_by(
            etudid=self.etud.id, formsemestre_id=self.formsemestre.id, ue_id=self.ue.id
        )
        for validation in validations:
            log(f"DecisionsProposeesUE: deleting {validation}")
            db.session.delete(validation)
            Scolog.logdb(
                method="jury_but",
                etudid=self.etud.id,
                msg=f"Validation UE {validation.ue.id} {validation.ue.acronyme}: effacée",
            )

        db.session.commit()

    def descr_validation(self) -> str:
        """Description validation niveau enregistrée, pour PV jury.
        Si l'UE est validée, donne son acronyme, sinon chaine vide.
        """
        if self.code_valide in sco_codes.CODES_UE_VALIDES:
            return f"{self.ue.acronyme}"
        return ""

    def ects_acquis(self) -> float:
        """ECTS enregistrés pour cette UE
        (0 si pas de validation enregistrée)
        """
        if self.validation and self.code_valide in sco_codes.CODES_UE_VALIDES:
            return self.ue.ects
        return 0.0


# class BUTCursusEtud:  # WIP TODO
#     """Validation du cursus d'un étudiant"""

#     def __init__(self, formsemestre: FormSemestre, etud: Identite):
#         if formsemestre.formation.referentiel_competence is None:
#             raise ScoNoReferentielCompetences(formation=formsemestre.formation)
#         assert len(etud.formsemestre_inscriptions) > 0
#         self.formsemestre = formsemestre
#         self.etud = etud
#         #
#         # La dernière inscription en date va donner le parcours (donc les compétences à valider)
#         self.last_inscription = sorted(
#             etud.formsemestre_inscriptions, key=attrgetter("formsemestre.date_debut")
#         )[-1]

#     def est_diplomable(self) -> bool:
#         """Vrai si toutes les compétences sont validables"""
#         return all(
#             self.competence_validable(competence)
#             for competence in self.competences_du_parcours()
#         )

#     def est_annee_validee(self, ordre: int) -> bool:
#         """Vrai si l'année BUT ordre est validée"""
#         return (
#             ApcValidationAnnee.query.filter_by(
#                 etudid=self.etud.id,
#                 ordre=ordre,
#                 referentiel_competence_id=self.formsemestre.formation.referentiel_competence_id
#             )
#             .count()
#             > 0
#         )

#     def est_diplome(self) -> bool:
#         """Vrai si BUT déjà validé"""
#         # vrai si la troisième année est validée
#         return self.est_annee_validee(3)

#     def competences_du_parcours(self) -> list[ApcCompetence]:
#         """Construit liste des compétences du parcours, qui doivent être
#         validées pour obtenir le diplôme.
#         Le parcours est celui de la dernière inscription.
#         """
#         parcour = self.last_inscription.parcour
#         query = self.formsemestre.formation.formation.query_competences_parcour(parcour)
#         if query is None:
#             return []
#         return query.all()

#     def competence_validee(self, competence: ApcCompetence) -> bool:
#         """Vrai si la compétence est validée, c'est à dire que tous ses
#         niveaux sont validés (ApcValidationRCUE).
#         """
#         # XXX A REVOIR
#         validations = (
#             ApcValidationRCUE.query.filter_by(etudid=self.etud.id)
#             .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
#             .join(ApcNiveau, ApcNiveau.id == UniteEns.niveau_competence_id)
#             .join(ApcCompetence, ApcCompetence.id == ApcNiveau.competence_id)
#         )

#     def competence_validable(self, competence: ApcCompetence):
#         """Vrai si la compétence est "validable" automatiquement, c'est à dire
#         que les conditions de notes sont satisfaites pour l'acquisition de
#         son niveau le plus élevé, qu'il ne manque que l'enregistrement de la décision.

#         En vertu de la règle "La validation des deux UE du niveau d'une compétence
#         emporte la validation de l'ensemble des UE du niveau inférieur de cette
#         même compétence.",
#         il suffit de considérer le dernier niveau dans lequel l'étudiant est inscrit.
#         """
#         pass

#     def ues_emportees(self, niveau: ApcNiveau) -> list[tuple[FormSemestre, UniteEns]]:
#         """La liste des UE à valider si on valide ce niveau.
#         Ne liste que les UE qui ne sont pas déjà acquises.

#         Selon la règle donnée par l'arrêté BUT:
#         * La validation des deux UE du niveau d'une compétence emporte la validation de
#         l'ensemble des UE du niveau inférieur de cette même compétence.
#         """
#         pass