Skip to content
Snippets Groups Projects
Select Git revision
  • dc4eb63afe3cfef4ecfd4321d027e15f65621d44
  • master default protected
2 results

jury_but.py

Blame
  • Forked from Jean-Marie Place / SCODOC_R6A06
    Source project has a limited visibility.
    jury_but.py 55.92 KiB
    ##############################################################################
    # ScoDoc
    # Copyright (c) 1999 - 2023 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.
    """
    import html
    from operator import attrgetter
    import re
    from typing import Union
    
    import numpy as np
    from flask import flash, g, url_for
    
    from app import db
    from app import log
    from app.comp.res_but import ResultatsSemestreBUT
    from app.comp import res_sem
    
    from app.models.but_refcomp import (
        ApcAnneeParcours,
        ApcCompetence,
        ApcNiveau,
        ApcParcours,
        ApcParcoursNiveauCompetence,
    )
    from app.models import Scolog, ScolarAutorisationInscription
    from app.models.but_validations import (
        ApcValidationAnnee,
        ApcValidationRCUE,
        RegroupementCoherentUE,
    )
    from app.models.etudiants import Identite
    from app.models.formations import Formation
    from app.models.formsemestre import FormSemestre, FormSemestreInscription
    from app.models.ues import UniteEns
    from app.models.validations import ScolarFormSemestreValidation
    from app.scodoc import sco_cache
    from app.scodoc import sco_codes_parcours as sco_codes
    from app.scodoc.sco_codes_parcours import (
        BUT_CODES_ORDERED,
        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: Union[str, list[str]] = 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.ABAN,
            sco_codes.ABL,
            sco_codes.ATJ,
            sco_codes.DEF,
            sco_codes.DEM,
            sco_codes.EXCLU,
        ]
    
        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 utilisé pour construire ce deca"
            self.formsemestre_id = formsemestre.id
            "l'id du formsemestre utilisé pour construire ce deca"
            formsemestre_impair, formsemestre_pair = self.comp_formsemestres(formsemestre)
            assert (
                (formsemestre_pair is None)
                or (formsemestre_impair is None)
                or (
                    ((formsemestre_pair.semestre_id - formsemestre_impair.semestre_id) == 1)
                    and (
                        formsemestre_pair.formation.referentiel_competence_id
                        == formsemestre_impair.formation.referentiel_competence_id
                    )
                )
            )
            # Si les années scolaires sont distinctes, on est "à cheval"
            self.a_cheval = (
                formsemestre_impair
                and formsemestre_pair
                and formsemestre_impair.annee_scolaire()
                != formsemestre_pair.annee_scolaire()
            )
            "vrai si on groupe deux semestres d'années scolaires différentes"
            # 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 (propose code annuel)"
    
            self.formsemestre_impair = formsemestre_impair
            "le 1er semestre du groupement (S1, S3, S5)"
            self.formsemestre_pair = formsemestre_pair
            "le second formsemestre (S2, S4, S6), de la même année scolaire ou d'une précédente"
            formsemestre_last = formsemestre_pair or formsemestre_impair
            "le formsemestre le plus avancé (en indice de semestre) dans le groupement"
    
            self.annee_but = (formsemestre_last.semestre_id + 1) // 2
            "le rang de l'année dans le BUT: 1, 2, 3"
            assert self.annee_but in (1, 2, 3)
            self.rcues_annee = []
            """RCUEs de l'année 
            (peuvent concerner l'année scolaire antérieur pour les redoublants 
            avec UE capitalisées)
            """
            self.inscription_etat = etud.inscription_etat(formsemestre_last.id)
            "état de l'inscription dans le semestre le plus avancé (pair si année complète)"
            self.inscription_etat_pair = (
                etud.inscription_etat(formsemestre_pair.id)
                if formsemestre_pair is not None
                else None
            )
            self.inscription_etat_impair = (
                etud.inscription_etat(formsemestre_impair.id)
                if formsemestre_impair is not None
                else None
            )
    
            if self.formsemestre_impair is not None:
                self.validation = ApcValidationAnnee.query.filter_by(
                    etudid=self.etud.id,
                    formsemestre_id=formsemestre_impair.id,
                    ordre=self.annee_but,
                ).first()
            else:
                self.validation = None
            if self.validation is not None:
                self.code_valide = self.validation.code
            self.parcour = None
            "Le parcours considéré (celui du semestre pair, ou à défaut impair)"
            if self.formsemestre_pair is not None:
                self.res_pair: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
                    self.formsemestre_pair
                )
            else:
                self.res_pair = None
            if self.formsemestre_impair is not None:
                self.res_impair: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
                    self.formsemestre_impair
                )
            else:
                self.res_impair = None
    
            self.ues_impair, self.ues_pair = self.compute_ues_annee()  # pylint: disable=all
            self.decisions_ues = {
                ue.id: DecisionsProposeesUE(
                    etud, formsemestre_impair, ue, self.inscription_etat_impair
                )
                for ue in self.ues_impair
            }
            "{ue_id : DecisionsProposeesUE} pour toutes les UE de l'année"
            self.decisions_ues.update(
                {
                    ue.id: DecisionsProposeesUE(
                        etud, formsemestre_pair, ue, self.inscription_etat_pair
                    )
                    for ue in self.ues_pair
                }
            )
            self.rcues_annee = self.compute_rcues_annee()
    
            formation = (
                self.formsemestre_impair.formation
                if self.formsemestre_impair
                else self.formsemestre_pair.formation
            )
            (
                parcours,
                niveaux_by_parcours,
            ) = formation.referentiel_competence.get_niveaux_by_parcours(
                self.annee_but, self.parcour
            )
            self.niveaux_competences = niveaux_by_parcours["TC"] + (
                niveaux_by_parcours[self.parcour.id] if self.parcour else []
            )
            """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.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"
            # XXX TODO ajouter condition pour passage en S5
    
            # Enfin calcule les codes des UE:
            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
            expl_rcues = f"""{self.nb_validables} niveau{"x" if plural else ""} validable{
                    "s" if plural else ""} 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,
                ]
                expl_rcues = ""
            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
                expl_rcues += f" et {self.nb_rcues_under_8} < 8"
            else:
                self.codes = [
                    sco_codes.RED,
                    sco_codes.NAR,
                    sco_codes.PAS1NCI,
                    sco_codes.ADJ,
                    sco_codes.PASD,  # voir #488 (discutable, conventions locales)
                ] + self.codes
                expl_rcues += 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)
            self.explanation = f"<div>{expl_rcues}</div>"
            messages = self.descr_pb_coherence()
            if messages:
                self.explanation += (
                    '<div class="warning">'
                    + '</div><div class="warning">'.join(messages)
                    + "</div>"
                )
            #
    
        def infos(self) -> str:
            """informations, for debugging purpose."""
            text = f"""<b>DecisionsProposeesAnnee</b>
            <ul>
            <li>Etudiant: <a href="{url_for("scolar.ficheEtud", 
                scodoc_dept=g.scodoc_dept, etudid=self.etud.id)
            }">{self.etud.nomprenom}</a>
            </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.to_html()} ({
                                formsemestre.formation.id})</a>
                        </li>
                        </ul>
                    """
                else:
                    text += " aucun."
                text += "</li>"
    
            text += f"""
                <li>RCUEs: {html.escape(str(self.rcues_annee))}</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 du niveau auquel appartient formsemestre.
            Complète le niveau avec le formsemestre antérieur le plus récent.
            L'"autre" formsemestre peut ainsi appartenir à l'année scolaire
            antérieure (redoublants).
            -> S_impair, S_pair
            """
            if not formsemestre.formation.is_apc():  # garde fou
                return None, None
            if formsemestre.semestre_id % 2 == 0:
                other_semestre_id = formsemestre.semestre_id - 1
            else:
                other_semestre_id = formsemestre.semestre_id + 1
    
            other_formsemestre = None
            for inscr in self.etud.formsemestre_inscriptions:
                if (
                    # Même spécialité BUT (tolère ainsi des variantes de formation)
                    (
                        inscr.formsemestre.formation.referentiel_competence
                        == formsemestre.formation.referentiel_competence
                    )
                    # L'autre semestre
                    and (inscr.formsemestre.semestre_id == other_semestre_id)
                    # Antérieur
                    and inscr.formsemestre.date_debut < formsemestre.date_debut
                    # Et plus le récent possible
                    and (
                        (other_formsemestre is None)
                        or (other_formsemestre.date_debut < inscr.formsemestre.date_debut)
                    )
                ):
                    other_formsemestre = inscr.formsemestre
            if formsemestre.semestre_id % 2 == 0:
                return other_formsemestre, formsemestre
            return formsemestre, other_formsemestre
    
        def compute_ues_annee(self) -> list[list[UniteEns], list[UniteEns]]:
            """UEs à valider cette année pour cet étudiant, selon son parcours.
            Affecte self.parcour suivant l'inscription de l'étudiant et
            ramène [ listes des UE du semestre impair, liste des UE du semestre pair ].
            """
            ues_sems = []
            for (formsemestre, res) in (
                (self.formsemestre_impair, self.res_impair),
                (self.formsemestre_pair, self.res_pair),
            ):
                if (formsemestre is None) or (not formsemestre.formation.is_apc()):
                    ues = []
                else:
                    parcour, ues = list_ue_parcour_etud(formsemestre, self.etud, res)
                    if parcour is not None:
                        self.parcour = parcour
                ues_sems.append(ues)
            return ues_sems
    
        def check_ues_ready_jury(self) -> list[str]:
            """Vérifie que les toutes les UEs (hors bonus) de l'année sont
            bien associées à des niveaux de compétences.
            Renvoie liste vide si ok, sinon liste de message explicatifs
            """
            messages = []
            for ue in self.ues_impair + self.ues_pair:
                if ue.niveau_competence is None:
                    messages.append(
                        f"UE {ue.acronyme} non associée à un niveau de compétence"
                    )
                if ue.semestre_idx is None:
                    messages.append(
                        f"UE {ue.acronyme} n'a pas d'indice de semestre dans la formation"
                    )
            return messages
    
        def compute_rcues_annee(self) -> list[RegroupementCoherentUE]:
            """Liste des regroupements d'UE à considérer cette année.
            On peut avoir un RCUE à cheval sur plusieurs années (redoublants avec UE capitalisées).
            Si on n'a pas les deux semestres, aucun RCUE.
            """
            if self.formsemestre_pair is None or self.formsemestre_impair is None:
                return []
            rcues_annee = []
            ues_impair_sans_rcue = {ue.id for ue in self.ues_impair}
            for ue_pair in self.ues_pair:
                rcue = None
                for ue_impair in self.ues_impair:
                    if self.a_cheval:
                        # l'UE paire DOIT être capitalisée pour être utilisée
                        if (
                            self.decisions_ues[ue_pair.id].code_valide
                            not in CODES_UE_VALIDES
                        ):
                            continue  # ignore cette UE antérieure non capitalisée
                        # et l'UE impaire doit être actuellement meilleure que
                        # celle éventuellement capitalisée
                        if self.decisions_ues[ue_impair.id].ue_status["is_capitalized"]:
                            continue  # ignore cette UE car capitalisée et actuelle moins bonne
                    if ue_pair.niveau_competence_id == ue_impair.niveau_competence_id:
                        rcue = RegroupementCoherentUE(
                            self.etud,
                            self.formsemestre_impair,
                            self.decisions_ues[ue_impair.id],
                            self.formsemestre_pair,
                            self.decisions_ues[ue_pair.id],
                            self.inscription_etat,
                        )
                        ues_impair_sans_rcue.discard(ue_impair.id)
                        break
                # if rcue is None and not self.a_cheval:
                #    raise NoRCUEError(deca=self, ue=ue_pair)
                if rcue is not None:
                    rcues_annee.append(rcue)
            # Si jury annuel (pas à cheval), on doit avoir tous les RCUEs:
            # if len(ues_impair_sans_rcue) > 0 and not self.a_cheval:
            #    ue = UniteEns.query.get(ues_impair_sans_rcue.pop())
            #    raise NoRCUEError(deca=self, ue=ue)
            return rcues_annee
    
        def compute_decisions_niveaux(self) -> dict[int, "DecisionsProposeesRCUE"]:
            """Pour chaque niveau de compétence de cette année, construit
            le DecisionsProposeesRCUE, ou None s'il n'y en a pas
                (ne devrait pas arriver car compute_rcues_annee vérifie déjà cela).
    
            Appelé à la construction du deca, donc avant décisions manuelles.
            Return: { niveau_id : DecisionsProposeesRCUE }
            """
            # Retrouve le RCUE associé à chaque niveau
            rc_niveaux = []
            for niveau in self.niveaux_competences:
                rcue = None
                for rc in self.rcues_annee:
                    if rc.ue_1.niveau_competence_id == niveau.id:
                        rcue = rc
                        break
                if rcue is not None:
                    dec_rcue = DecisionsProposeesRCUE(self, rcue, self.inscription_etat)
                    rc_niveaux.append((dec_rcue, niveau.id))
                    # prévient les UE concernées :-)
                    self.decisions_ues[dec_rcue.rcue.ue_1.id].set_rcue(dec_rcue.rcue)
                    self.decisions_ues[dec_rcue.rcue.ue_2.id].set_rcue(dec_rcue.rcue)
            # Ordonne par numéro d'UE
            rc_niveaux.sort(key=lambda x: x[0].rcue.ue_1.numero)
            decisions_rcue_by_niveau = {x[1]: x[0] for x in rc_niveaux}
            return decisions_rcue_by_niveau
    
        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():
                d[dec_rcue.rcue.ue_1.id] = dec_rcue
                d[dec_rcue.rcue.ue_2.id] = dec_rcue
            return d
    
        def next_annee_semestre_id(self, code: str) -> int:
            """L'indice du semestre dans lequel l'étudiant est autorisé à
            poursuivre l'année suivante. None si aucun."""
            if self.formsemestre_pair is None:
                return None  # seulement sur année
            if code == RED:
                return self.formsemestre_pair.semestre_id - 1
            elif (
                code in sco_codes.BUT_CODES_PASSAGE
                and self.formsemestre_pair.semestre_id < sco_codes.ParcoursBUT.NB_SEM
            ):
                return self.formsemestre_pair.semestre_id + 1
            return None
    
        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.
            """
            log("jury_but.DecisionsProposeesAnnee.record_form")
            code_annee = None
            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_all()
    
            db.session.commit()
    
        def record(self, code: str, no_overwrite=False):
            """Enregistre le code de l'année, et au besoin l'autorisation d'inscription.
            Si no_overwrite, ne fait rien si un code est déjà enregistré.
            Si l'étudiant est DEM ou DEF, ne fait rien.
            """
            if self.inscription_etat != scu.INSCRIT:
                return
            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 or (self.code_valide is not None and no_overwrite):
                self.recorded = True
                return  # no change
            if self.validation:
                db.session.delete(self.validation)
                db.session.commit()
            if code is None:
                self.validation = None
            else:
                self.validation = ApcValidationAnnee(
                    etudid=self.etud.id,
                    formsemestre=self.formsemestre_impair,
                    ordre=self.annee_but,
                    annee_scolaire=self.annee_scolaire(),
                    code=code,
                )
                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}",
                )
    
            # --- Autorisation d'inscription dans semestre suivant ?
            if self.formsemestre_pair is not None:
                if code is None:
                    ScolarAutorisationInscription.delete_autorisation_etud(
                        etudid=self.etud.id,
                        origin_formsemestre_id=self.formsemestre_pair.id,
                    )
                else:
                    next_semestre_id = self.next_annee_semestre_id(code)
                    if next_semestre_id is not None:
                        ScolarAutorisationInscription.autorise_etud(
                            self.etud.id,
                            self.formsemestre_pair.formation.formation_code,
                            self.formsemestre_pair.id,
                            next_semestre_id,
                        )
    
            self.recorded = True
            db.session.commit()
            self.invalidate_formsemestre_cache()
    
        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 record_all(self, no_overwrite: bool = True):
            """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.
            """
            # 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 (
                    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
                    # enregistre le code jury seulement s'il n'y a pas déjà de code
                    # (no_overwrite=True) sauf en mode test yaml
                    dec_ue.record(code, no_overwrite=no_overwrite)
            # RCUE : enregistre seulement si pas déjà validé "mieux"
            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 (
                    (not dec_rcue.validation)
                    or BUT_CODES_ORDERED.get(dec_rcue.validation.code, 0)
                    < BUT_CODES_ORDERED.get(code, 0)
                ):
                    dec_rcue.record(code, no_overwrite=no_overwrite)
            # 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
                # enregistre le code jury seulement s'il n'y a pas déjà de code
                # (no_overwrite=True) sauf en mode test yaml
                self.record(code, no_overwrite=no_overwrite)
    
        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 à cheval, n'efface que pour le semestre d'origine du deca.
            """
            if only_one_sem or self.a_cheval:
                # 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.formsemestre.id == self.formsemestre_id:
                        dec_ue.erase()
            else:
                for dec_ue in self.decisions_ues.values():
                    dec_ue.erase()
                for dec_rcue in self.decisions_rcue_by_niveau.values():
                    dec_rcue.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
                    )
                validations = ApcValidationAnnee.query.filter_by(
                    etudid=self.etud.id,
                    formsemestre_id=self.formsemestre_impair.id,
                    ordre=self.annee_but,
                )
                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]:
            """Les liste des indices de semestres auxquels on est autorisé à
            s'inscrire depuis cette année"""
            formsemestre = self.formsemestre_pair or self.formsemestre_impair
            if not formsemestre:
                return []
            return [
                a.semestre_id
                for a in ScolarAutorisationInscription.query.filter_by(
                    etudid=self.etud.id,
                    origin_formsemestre_id=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):
                        dec_ue = self.decisions_ues.get(ue.id)
                        if dec_ue:
                            if dec_ue.code_valide not in CODES_UE_VALIDES:
                                messages.append(
                                    f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est !"
                                )
                        else:
                            messages.append(f"L'UE {ue.acronyme} n'a pas décision (???)")
            return messages
    
    
    def list_ue_parcour_etud(
        formsemestre: FormSemestre, etud: Identite, res: ResultatsSemestreBUT
    ) -> tuple[ApcParcours, list[UniteEns]]:
        """Parcour dans lequel l'étudiant est inscrit,
        et liste des UEs à valider pour ce semestre (sans les UE "dispensées")
        """
        if res.etuds_parcour_id[etud.id] is None:
            parcour = 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:
            parcour = ApcParcours.query.get(res.etuds_parcour_id[etud.id])
            ues = (
                formsemestre.formation.query_ues_parcour(parcour)
                .filter_by(semestre_idx=formsemestre.semestre_id)
                .order_by(UniteEns.numero)
                .all()
            )
        ues = [ue for ue in ues if (etud.id, ue.id) not in res.dispense_ues]
        return parcour, 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.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 = 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.modalite == "EXT"
                or 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_ORDERED.get(
                    self.code_valide, 0
                ) > sco_codes.BUT_CODES_ORDERED.get(code_default, 0):
                    self.codes.insert(0, self.code_valide)
                else:
                    self.codes.insert(1, self.code_valide)
    
        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, no_overwrite=False):
            """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  # pas de RCUE a enregistrer
            if self.inscription_etat != scu.INSCRIT:
                return
            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 or (self.code_valide is not None and no_overwrite):
                self.recorded = True
                return  # 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.rcue.formsemestre_2.id,
                    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)
                # TODO
    
            if self.rcue.formsemestre_1 is not None:
                sco_cache.invalidate_formsemestre(
                    formsemestre_id=self.rcue.formsemestre_1.id
                )
            if self.rcue.formsemestre_2 is not None:
                sco_cache.invalidate_formsemestre(
                    formsemestre_id=self.rcue.formsemestre_2.id
                )
            self.code_valide = code  # mise à jour état
            self.recorded = 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é, done 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} niv. {ordre}"
            return ""
    
    
    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)
        """
    
        # 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,
            ue: UniteEns,
            inscription_etat: str = scu.INSCRIT,
        ):
            # 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)
            validation = ScolarFormSemestreValidation.query.filter_by(
                etudid=etud.id, formsemestre_id=formsemestre.id, ue_id=ue.id
            ).first()
            super().__init__(
                etud=etud,
                code_valide=validation.code if validation is not None else None,
            )
            self.validation = validation
            self.formsemestre = formsemestre
            self.ue: UniteEns = ue
            self.rcue: RegroupementCoherentUE = None
            "Le rcue auquel est rattaché cette UE, ou None"
            self.inscription_etat = inscription_etat
            "inscription: I, DEM, DEF dans le semestre de cette UE"
            self.moy_ue = np.NaN
            self.moy_ue_with_cap = np.NaN
            self.ue_status = {}
    
            if ue.type == sco_codes.UE_SPORT:
                self.explanation = "UE bonus, pas de décision de jury"
                self.codes = []  # aucun code proposé
                return
            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
    
            # Moyenne de l'UE ?
            res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
            # Safety checks:
            if not ue.id in res.etud_moy_ue:
                self.explanation = "UE sans résultat"
                return
            if not etud.id in res.etud_moy_ue[ue.id]:
                self.explanation = "Étudiant sans résultat dans cette UE"
                return
            ue_status = res.get_etud_ue_status(etud.id, ue.id)
            self.moy_ue = ue_status["cur_moy_ue"]
            self.moy_ue_with_cap = ue_status["moy"]
            self.ue_status = ue_status
    
        def __repr__(self) -> str:
            return f"""<{self.__class__.__name__} ue={self.ue.acronyme} valid={self.code_valide
            } codes={self.codes} explanation={self.explanation}>"""
    
        def set_rcue(self, rcue: RegroupementCoherentUE):
            """Rattache cette UE à un RCUE. Cela peut modifier les codes
            proposés par compute_codes() (si compensation)"""
            self.rcue = rcue
    
        def compute_codes(self):
            """Calcul des .codes attribuables et de l'explanation associée"""
            if self.inscription_etat != scu.INSCRIT:
                return
            if (
                self.moy_ue > (sco_codes.ParcoursBUT.BARRE_MOY - sco_codes.NOTES_TOLERANCE)
            ) or self.formsemestre.modalite == "EXT":
                self.codes.insert(0, sco_codes.ADM)
                self.explanation = f"Moyenne >= {sco_codes.ParcoursBUT.BARRE_MOY}/20"
            elif self.rcue and self.rcue.est_compensable():
                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, no_overwrite=False):
            """Enregistre le code jury pour cette UE.
            Si no_overwrite, n'enregistre pas s'il y a déjà un code.
            """
            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 or (self.code_valide is not None and no_overwrite):
                self.recorded = True
                return  # 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
    
        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
            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 ""
    
    
    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_diplome(self) -> bool:
            """Vrai si BUT déjà validé"""
            # vrai si la troisième année est validée
            # On cherche les validations de 3ieme annee (ordre=3) avec le même référentiel
            # de formation que nous.
            return (
                ApcValidationAnnee.query.filter_by(etudid=self.etud.id, ordre=3)
                .join(FormSemestre, FormSemestre.id == ApcValidationAnnee.formsemestre_id)
                .join(Formation, FormSemestre.formation_id == Formation.id)
                .filter(
                    Formation.referentiel_competence_id
                    == self.formsemestre.formation.referentiel_competence_id
                )
                .count()
                > 0
            )
    
        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