diff --git a/app/but/jury_but.py b/app/but/jury_but.py
index fa956c8668ad86136c9ff8277d7b15c501a2d6cf..4fd87833706f0e75ce2118a1996865ee0f3bc34f 100644
--- a/app/but/jury_but.py
+++ b/app/but/jury_but.py
@@ -5,163 +5,463 @@
##############################################################################
"""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 operator import attrgetter
+from typing import Union
+from app import log
from app.comp.res_but import ResultatsSemestreBUT
from app.comp import res_sem
-from app.models import but_validations
+
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence,
ApcNiveau,
+ ApcParcours,
ApcParcoursNiveauCompetence,
)
-from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
+from app.models import but_validations
+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
+from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.ues import UniteEns
-from app.scodoc import sco_codes_parcours as codes
+from app.models.validations import ScolarFormSemestreValidation
+from app.scodoc import sco_codes_parcours as sco_codes
from app.scodoc import sco_utils as scu
-from app.scodoc.sco_exceptions import ScoException
+from app.scodoc.sco_exceptions import ScoException, ScoValueError
-class RegroupementCoherentUE:
- def __init__(
- self,
- etud: Identite,
- formsemestre_1: FormSemestre,
- ue_1: UniteEns,
- formsemestre_2: FormSemestre,
- ue_2: UniteEns,
- ):
- self.formsemestre_1 = formsemestre_1
- self.ue_1 = ue_1
- self.formsemestre_2 = formsemestre_2
- self.ue_2 = ue_2
- # stocke les moyennes d'UE
- res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_1)
- if ue_1.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_1.id]:
- self.moy_ue_1 = res.etud_moy_ue[ue_1.id][etud.id]
- else:
- self.moy_ue_1 = None
- res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_2)
- if ue_2.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_2.id]:
- self.moy_ue_2 = res.etud_moy_ue[ue_1.id][etud.id]
- else:
- self.moy_ue_2 = None
- # Calcul de la moyenne au RCUE
- if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
- # Moyenne RCUE non pondérée (pour le moment)
- self.moy_rcue = (self.moy_ue_1 + self.moy_ue_2) / 2
- else:
- self.moy_rcue = None
+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 de d'une classe avec un champ code
+ ApcValidationRCUE, ApcValidationAnnee ou ScolarFormSemestreValidation
+ """
-class DecisionsProposees:
# Codes toujours proposés sauf si include_communs est faux:
- codes_communs = [codes.RAT, codes.DEF, codes.ABAN, codes.DEM, codes.UEBSL]
+ codes_communs = [
+ sco_codes.RAT,
+ sco_codes.DEF,
+ sco_codes.ABAN,
+ sco_codes.DEM,
+ sco_codes.UEBSL,
+ ]
- def __init__(self, code: str = None, explanation="", include_communs=True):
+ 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
- else:
- self.codes = []
if isinstance(code, list):
self.codes = code + self.codes_communs
elif code is not None:
self.codes = [code] + self.codes_communs
- self.explanation = explanation
+ self.code_valide: str = code_valide
+ "La décision actuelle enregistrée"
+ self.explanation: str = explanation
+ "Explication en à afficher à côté de la décision"
def __repr__(self) -> str:
- return f"""<{self.__class__.__name__} codes={self.codes} explanation={self.explanation}"""
-
+ return f"""<{self.__class__.__name__} valid={self.code_valide
+ } codes={self.codes} explanation={self.explanation}"""
-def decisions_ue_proposees(
- etud: Identite, formsemestre: FormSemestre, ue: UniteEns
-) -> DecisionsProposees:
- """Liste des codes de décisions que l'on peut proposer pour
- cette UE de cet étudiant dans ce semestre.
- si DEF ou DEM ou ABAN ou ABL sur année BUT: seulement DEF, DEM, ABAN, ABL
+class DecisionsProposeesAnnee(DecisionsProposees):
+ """Décisions de jury sur une année (ETP) du BUT
- si moy_ue > 10, ADM
- sinon si compensation dans RCUE: CMP
- sinon: ADJ, AJ
- et proposer toujours: RAT, DEF, ABAN, DEM, UEBSL
+ 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.
"""
- if ue.type == codes.UE_SPORT:
- return DecisionsProposees(
- explanation="UE bonus, pas de décision de jury", include_communs=False
- )
- # Code sur année ?
- decision_annee = ApcValidationAnnee.query.filter_by(
- etudid=etud.id, annee_scolaire=formsemestre.annee_scolaire()
- ).first()
- if (
- decision_annee is not None and decision_annee.code in codes.CODES_ANNEE_ARRET
- ): # DEF, DEM, ABAN, ABL
- return DecisionsProposees(
- code=decision_annee.code,
- explanation=f"l'année a le code {decision_annee.code}",
- include_communs=False,
+
+ # Codes toujours proposés sauf si include_communs est faux:
+ codes_communs = [
+ sco_codes.RAT,
+ sco_codes.ABAN,
+ sco_codes.ABL,
+ sco_codes.DEF,
+ sco_codes.DEM,
+ sco_codes.EXCLU,
+ ]
+
+ def __init__(
+ self,
+ etud: Identite,
+ formsemestre: FormSemestre,
+ ):
+ super().__init__(etud=etud)
+ 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
+ )
+ )
)
- # Moyenne de l'UE ?
- res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
- if not ue.id in res.etud_moy_ue:
- return DecisionsProposees(explanation="UE sans résultat")
- if not etud.id in res.etud_moy_ue[ue.id]:
- return DecisionsProposees(explanation="Étudiant sans résultat dans cette UE")
- moy_ue = res.etud_moy_ue[ue.id][etud.id]
- if moy_ue > (codes.ParcoursBUT.BARRE_MOY - codes.NOTES_TOLERANCE):
- return DecisionsProposees(
- code=codes.ADM,
- explanation=f"Moyenne >= {codes.ParcoursBUT.BARRE_MOY}/20",
+
+ self.formsemestre_impair = formsemestre_impair
+ "le 1er semestre de l'année scolaire considérée (S1, S3, S5)"
+ self.formsemestre_pair = formsemestre_pair
+ "le second formsemestre de la même année scolaire (S2, S4, S6)"
+ self.annee_but = formsemestre_impair.semestre_id // 2 + 1
+ "le rang de l'année dans le BUT: 1, 2, 3"
+ assert self.annee_but in (1, 2, 3)
+ self.validation = ApcValidationAnnee.query.filter_by(
+ etudid=self.etud.id,
+ formsemestre_id=formsemestre_impair.id,
+ ordre=self.annee_but,
+ ).first()
+ 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)"
+ self.ues_impair, self.ues_pair = self.compute_ues_annee() # pylint: disable=all
+ assert self.parcour is not None
+ self.rcues_annee = self.compute_rcues_annee()
+ "RCUEs de l'année"
+
+ self.nb_competences = len(
+ ApcNiveau.niveaux_annee_de_parcours(self.parcour, self.annee_but).all()
+ ) # note that .count() won't give the same res
+ self.nb_validables = len(
+ [rcue for rcue in self.rcues_annee if rcue.est_validable()]
)
- # Compensation dans le RCUE ?
- other_ue, other_formsemestre = but_validations.get_other_ue_rcue(ue, etud.id)
- if other_ue is not None:
- # inscrit à une autre UE du même RCUE
- other_res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
- other_formsemestre
+ self.nb_rcues_under_8 = len(
+ [rcue for rcue in self.rcues_annee if not rcue.est_suffisant()]
)
- if (other_ue.id in other_res.etud_moy_ue) and (
- etud.id in other_res.etud_moy_ue[other_ue.id]
- ):
- other_moy_ue = other_res.etud_moy_ue[other_ue.id][etud.id]
- # Moyenne RCUE: non pondérée (pour le moment)
- moy_rcue = (moy_ue + other_moy_ue) / 2
- if moy_rcue > codes.NOTES_BARRE_GEN_COMPENSATION: # 10-epsilon
- return DecisionsProposees(
- code=codes.CMP,
- explanation=f"Compensée par {other_ue} (moyenne RCUE={scu.fmt_note(moy_rcue)}/20",
+ # année ADM si toutes RCUE validées (sinon PASD)
+ admis = self.nb_validables == self.nb_competences
+ valide_moitie_rcue = self.nb_validables > self.nb_competences // 2
+ # Peut passer si plus de la moitié validables et tous > 8
+ passage_de_droit = valide_moitie_rcue and (self.nb_rcues_under_8 == 0)
+ # XXX TODO ajouter condition pour passage en S5
+
+ # Reste à attribuer ADM, ADJ, PASD, PAS1NCI, RED, NAR
+ expl_rcues = f"{self.nb_validables} validables sur {self.nb_competences}"
+ if admis:
+ self.codes = [sco_codes.ADM] + self.codes
+ self.explanation = expl_rcues
+ elif passage_de_droit:
+ self.codes = [sco_codes.PASD, sco_codes.ADJ] + self.codes
+ self.explanation = expl_rcues
+ elif valide_moitie_rcue: # mais au moins 1 rcue insuffisante
+ self.codes = [sco_codes.PAS1NCI, sco_codes.ADJ] + self.codes
+ self.explanation = expl_rcues + f" et {self.nb_rcues_under_8} < 8"
+ else:
+ self.codes = [sco_codes.RED, sco_codes.NAR, sco_codes.ADJ] + self.codes
+ self.explanation = expl_rcues + f" et {self.nb_rcues_under_8} < 8"
+ #
+
+ def infos(self) -> str:
+ "informations, for debugging purpose"
+ return f"""DecisionsProposeesAnnee
+ etud: {self.etud}
+ formsemestre_pair: {self.formsemestre_pair}
+ formsemestre_impair: {self.formsemestre_impair}
+ RCUEs: {self.rcues_annee}
+ nb_competences: {self.nb_competences}
+ nb_nb_validables: {self.nb_validables}
+ codes: {self.codes}
+ explanation: {self.explanation}
+ """
+
+ def comp_formsemestres(
+ self, formsemestre: FormSemestre
+ ) -> tuple[FormSemestre, FormSemestre]:
+ "les deux formsemestres de l'année scolaire à laquelle appartient formsemestre"
+ if formsemestre.semestre_id % 2 == 0:
+ other_semestre_id = formsemestre.semestre_id - 1
+ else:
+ other_semestre_id = formsemestre.semestre_id + 1
+ annee_scolaire = formsemestre.annee_scolaire()
+ 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)
+ # de la même année scolaire:
+ and (inscr.formsemestre.annee_scolaire() == annee_scolaire)
+ ):
+ 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.
+ Ramène [ listes des UE du semestre impair, liste des UE du semestre pair ].
+ """
+ etudid = self.etud.id
+ ues_sems = []
+ for formsemestre in self.formsemestre_impair, self.formsemestre_pair:
+ if formsemestre is None:
+ ues = []
+ else:
+ formation: Formation = formsemestre.formation
+ res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
+ formsemestre
+ )
+ # Parcour dans lequel l'étudiant est inscrit, et liste des UEs
+ if res.etuds_parcour_id[etudid] is None:
+ # pas de parcour: prend toutes les UEs (non bonus)
+ ues = res.etud_ues(etudid)
+ else:
+ parcour = ApcParcours.query.get(res.etuds_parcour_id[etudid])
+ if parcour is not None:
+ self.parcour = parcour
+ ues = (
+ formation.query_ues_parcour(parcour)
+ .filter_by(semestre_idx=formsemestre.semestre_id)
+ .all()
+ )
+ 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 DecisionsProposees(
- code=[codes.AJ, codes.ADJ],
- explanation="notes insuffisantes",
- )
-
-
-def decisions_rcue_proposees(
- etud: Identite,
- formsemestre_1: FormSemestre,
- ue_1: UniteEns,
- formsemestre_2: FormSemestre,
- ue_2: UniteEns,
-) -> DecisionsProposees:
+ return messages
+
+ def compute_rcues_annee(self) -> list[RegroupementCoherentUE]:
+ """Liste des regroupements d'UE à considérer cette année.
+ Pour le moment on ne considère pas de RCUE à cheval sur plusieurs années (redoublants).
+ Si on n'a pas les deux semestres, aucun RCUE.
+ Raises ScoValueError s'il y a des UE sans 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 ue_pair.niveau_competence_id == ue_impair.niveau_competence_id:
+ rcue = RegroupementCoherentUE(
+ self.etud,
+ self.formsemestre_impair,
+ ue_impair,
+ self.formsemestre_pair,
+ ue_pair,
+ )
+ ues_impair_sans_rcue.remove(ue_impair.id)
+ break
+ if rcue is None:
+ raise ScoValueError(f"pas de RCUE pour l'UE {ue_pair.acronyme}")
+ rcues_annee.append(rcue)
+ if len(ues_impair_sans_rcue) > 0:
+ ue = ues_impair_sans_rcue.pop()
+ raise ScoValueError(f"pas de RCUE pour l'UE {ue.acronyme}")
+ return rcues_annee
+
+
+class DecisionsProposeesRCUE(DecisionsProposees):
"""Liste des codes de décisions que l'on peut proposer pour
- le RCUE de cet étudiant dans ces semestres.
+ le RCUE de cet étudiant dans cette année.
ADM, CMP, ADJ, AJ, RAT, DEF, ABAN
+ """
- 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.
+ codes_communs = [
+ sco_codes.ADJ,
+ sco_codes.RAT,
+ sco_codes.DEF,
+ sco_codes.ABAN,
+ ]
+
+ def __init__(
+ self, dec_prop_annee: DecisionsProposeesAnnee, rcue: RegroupementCoherentUE
+ ):
+ super().__init__(etud=dec_prop_annee.etud)
+ self.rcue = rcue
+
+ validation = rcue.query_validations().first()
+ if validation is not None:
+ self.code_valide = validation.code
+ if rcue.est_compense():
+ self.codes.insert(0, sco_codes.CMP)
+ elif rcue.est_validable():
+ self.codes.insert(0, sco_codes.ADM)
+ else:
+ self.codes.insert(0, sco_codes.AJ)
+
+
+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, 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.DEM,
+ sco_codes.UEBSL,
+ ]
+
+ def __init__(
+ self,
+ etud: Identite,
+ formsemestre: FormSemestre,
+ ue: UniteEns,
+ ):
+ super().__init__(etud=etud)
+ self.ue: UniteEns = ue
+ self.rcue: RegroupementCoherentUE = None
+ # 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.validation = ScolarFormSemestreValidation.query.filter_by(
+ etudid=self.etud.id, formsemestre_id=formsemestre.id, ue_id=ue.id
+ ).first()
+ if self.validation is not None:
+ self.code_valide = self.validation.code
+ if ue.type == sco_codes.UE_SPORT:
+ self.explanation = "UE bonus, pas de décision de jury"
+ self.codes = [] # aucun code proposé
+ return
+ # Code sur année ?
+ decision_annee = ApcValidationAnnee.query.filter_by(
+ etudid=etud.id, annee_scolaire=formsemestre.annee_scolaire()
+ ).first()
+ if (
+ decision_annee is not None
+ and decision_annee.code in sco_codes.CODES_ANNEE_ARRET
+ ): # DEF, DEM, ABAN, ABL
+ self.explanation = f"l'année a le code {decision_annee.code}"
+ self.codes = [decision_annee.code] # sans les codes communs
+ return
+ # Moyenne de l'UE ?
+ res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
+ 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
+ moy_ue = res.etud_moy_ue[ue.id][etud.id]
+ if moy_ue > (sco_codes.ParcoursBUT.BARRE_MOY - sco_codes.NOTES_TOLERANCE):
+ self.codes.insert(0, sco_codes.ADM)
+ self.explanation = (f"Moyenne >= {sco_codes.ParcoursBUT.BARRE_MOY}/20",)
+
+ # Compensation dans un RCUE ?
+ rcues = but_validations.find_rcues(formsemestre, ue, etud)
+ for rcue in rcues:
+ if rcue.est_validable():
+ self.codes.insert(0, sco_codes.CMP)
+ self.explanation = f"Compensée par {rcue.other_ue(ue)} (moyenne RCUE={scu.fmt_note(rcue.moy_rcue)}/20"
+ self.rcue = rcue
+ return # s'arrête au 1er RCU validable
+
+ # Échec à valider cette UE
+ self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes
+ self.explanation = "notes insuffisantes"
-class BUTCursusEtud:
+class BUTCursusEtud: # WIP TODO
"""Validation du cursus d'un étudiant"""
def __init__(self, formsemestre: FormSemestre, etud: Identite):
@@ -215,6 +515,7 @@ class BUTCursusEtud:
"""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)
diff --git a/app/comp/res_but.py b/app/comp/res_but.py
index 0ea2e2eaf86a04eddcf0dbc2e13ef5d0dd9afc52..b139a6ab0a449ef79b4001c3042102fce60ab9e4 100644
--- a/app/comp/res_but.py
+++ b/app/comp/res_but.py
@@ -29,6 +29,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
"modimpl_coefs_df",
"modimpls_evals_poids",
"sem_cube",
+ "etuds_parcour_id", # parcours de chaque étudiant
"ues_inscr_parcours_df", # inscriptions aux UE / parcours
)
@@ -37,7 +38,8 @@ class ResultatsSemestreBUT(NotesTableCompat):
self.sem_cube = None
"""ndarray (etuds x modimpl x ue)"""
-
+ self.etuds_parcour_id = None
+ """Parcours de chaque étudiant { etudid : parcour_id }"""
if not self.load_cached():
t0 = time.time()
self.compute()
@@ -190,13 +192,14 @@ class ResultatsSemestreBUT(NotesTableCompat):
La matrice avec ue ne comprend que les UE non bonus.
1.0 si étudiant inscrit à l'UE, NaN sinon.
"""
- etuds_parcours = {
+ etuds_parcour_id = {
inscr.etudid: inscr.parcour_id for inscr in self.formsemestre.inscriptions
}
+ self.etuds_parcour_id = etuds_parcour_id
ue_ids = [ue.id for ue in self.ues]
# matrice de 1, inscrits par défaut à toutes les UE:
ues_inscr_parcours_df = pd.DataFrame(
- 1.0, index=etuds_parcours.keys(), columns=ue_ids, dtype=float
+ 1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
)
if self.formsemestre.formation.referentiel_competence is None:
return ues_inscr_parcours_df
@@ -209,11 +212,11 @@ class ResultatsSemestreBUT(NotesTableCompat):
parcour
).filter_by(semestre_idx=self.formsemestre.semestre_id)
}
- for etudid in etuds_parcours:
- parcour = etuds_parcours[etudid]
+ for etudid in etuds_parcour_id:
+ parcour = etuds_parcour_id[etudid]
if parcour is not None:
ues_inscr_parcours_df.loc[etudid] = ue_by_parcours[
- etuds_parcours[etudid]
+ etuds_parcour_id[etudid]
]
return ues_inscr_parcours_df
diff --git a/app/models/but_validations.py b/app/models/but_validations.py
index 7d58ace45f640030c2f52640030e1331d33566e5..294d61a0c4b92862a21cd0f79bd0f4cb8e857a7a 100644
--- a/app/models/but_validations.py
+++ b/app/models/but_validations.py
@@ -3,13 +3,18 @@
"""Décisions de jury (validations) des RCUE et années du BUT
"""
+import flask_sqlalchemy
from sqlalchemy.sql import text
+from typing import Union
from app import db
+
from app.models import CODE_STR_LEN
from app.models.but_refcomp import ApcNiveau
+from app.models.etudiants import Identite
from app.models.ues import UniteEns
from app.models.formsemestre import FormSemestre
+from app.scodoc import sco_codes_parcours as sco_codes
class ApcValidationRCUE(db.Model):
@@ -17,6 +22,7 @@ class ApcValidationRCUE(db.Model):
aka "regroupements cohérents d'UE" dans le jargon BUT.
+ le formsemestre est celui du semestre PAIR du niveau de compétence
"""
__tablename__ = "apc_validation_rcue"
@@ -58,16 +64,151 @@ class ApcValidationRCUE(db.Model):
return self.ue2.niveau_competence
-def get_other_ue_rcue(ue: UniteEns, etudid: int) -> tuple[UniteEns, FormSemestre]:
- """L'autre UE du RCUE (niveau de compétence) pour cet étudiant.
+# Attention: ce n'est pas un modèle mais une classe ordinaire:
+class RegroupementCoherentUE:
+ """Le regroupement cohérent d'UE, dans la terminologie du BUT, est le couple d'UEs
+ de la même année (BUT1,2,3) liées au même niveau de compétence.
- Cherche une UE du même niveau de compétence, à laquelle l'étudiant soit inscrit.
- Résultat: le couple (UE, FormSemestre), ou (None, None) si pas trouvée.
+ La moyenne (10/20) au RCU déclenche la compensation des UE.
+ """
+
+ def __init__(
+ self,
+ etud: Identite,
+ formsemestre_1: FormSemestre,
+ ue_1: UniteEns,
+ formsemestre_2: FormSemestre,
+ ue_2: UniteEns,
+ ):
+ from app.comp import res_sem
+ from app.comp.res_but import ResultatsSemestreBUT
+
+ # Ordonne les UE dans le sens croissant (S1,S2) ou (S3,S4)...
+ if formsemestre_1.semestre_id > formsemestre_2.semestre_id:
+ (ue_1, formsemestre_1), (ue_2, formsemestre_2) = (
+ (
+ ue_2,
+ formsemestre_2,
+ ),
+ (ue_1, formsemestre_1),
+ )
+ assert formsemestre_1.semestre_id % 2 == 1
+ assert formsemestre_2.semestre_id % 2 == 0
+ assert abs(formsemestre_1.semestre_id - formsemestre_2.semestre_id) == 1
+ assert ue_1.niveau_competence_id == ue_2.niveau_competence_id
+ self.etud = etud
+ self.formsemestre_1 = formsemestre_1
+ "semestre impair"
+ self.ue_1 = ue_1
+ self.formsemestre_2 = formsemestre_2
+ "semestre pair"
+ self.ue_2 = ue_2
+ # Stocke les moyennes d'UE
+ res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_1)
+ if ue_1.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_1.id]:
+ self.moy_ue_1 = res.etud_moy_ue[ue_1.id][etud.id]
+ self.moy_ue_1_val = self.moy_ue_1 # toujours float, peut être NaN
+ else:
+ self.moy_ue_1 = None
+ self.moy_ue_1_val = 0.0
+ res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_2)
+ if ue_2.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_2.id]:
+ self.moy_ue_2 = res.etud_moy_ue[ue_2.id][etud.id]
+ self.moy_ue_2_val = self.moy_ue_2
+ else:
+ self.moy_ue_2 = None
+ self.moy_ue_2_val = 0.0
+ # Calcul de la moyenne au RCUE
+ if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
+ # Moyenne RCUE non pondérée (pour le moment -- TODO)
+ self.moy_rcue = (self.moy_ue_1 + self.moy_ue_2) / 2
+ else:
+ self.moy_rcue = None
+
+ def __repr__(self) -> str:
+ return f"<{self.__class__.__name__} {self.ue_1.acronyme}({self.moy_ue_1}) {self.ue_2.acronyme}({self.moy_ue_2})>"
+
+ def query_validations(
+ self,
+ ) -> flask_sqlalchemy.BaseQuery: # list[ApcValidationRCUE]
+ """Les validations de jury enregistrées pour ce RCUE"""
+ niveau = self.ue_2.niveau_competence
+
+ return (
+ ApcValidationRCUE.query.filter_by(
+ etudid=self.etud.id,
+ )
+ .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
+ .join(ApcNiveau, UniteEns.niveau_id == ApcNiveau.id)
+ .filter(ApcNiveau.id == niveau.id)
+ )
+
+ def other_ue(self, ue: UniteEns) -> UniteEns:
+ """L'autre UE du regroupement. Si ue ne fait pas partie du regroupement, ValueError"""
+ if ue.id == self.ue_1.id:
+ return self.ue_2
+ elif ue.id == self.ue_2.id:
+ return self.ue_1
+ raise ValueError(f"ue {ue} hors RCUE {self}")
+
+ def est_enregistre(self) -> bool:
+ """Vrai si ce RCUE, donc le niveau de compétences correspondant
+ a une décision jury enregistrée
+ """
+ return self.query_validations().count() > 0
+
+ def est_compense(self):
+ """Vrai si ce RCUE est validable par compensation
+ c'est à dire que sa moyenne est > 10 avec une UE < 10
+ """
+ return (
+ (self.moy_rcue is not None)
+ and (self.moy_rcue > sco_codes.BUT_BARRE_RCUE)
+ and (
+ (self.moy_ue_1_val < sco_codes.NOTES_BARRE_GEN)
+ or (self.moy_ue_2_val < sco_codes.NOTES_BARRE_GEN)
+ )
+ )
+
+ def est_suffisant(self) -> bool:
+ """Vrai si ce RCUE est > 8"""
+ return (self.moy_rcue is not None) and (
+ self.moy_rcue > sco_codes.BUT_RCUE_SUFFISANT
+ )
+
+ def est_validable(self) -> bool:
+ """Vrai si ce RCU satisfait les conditions pour être validé
+ Pour cela, il suffit que la moyenne des UE qui le constitue soit > 10
+ """
+ return (self.moy_rcue is not None) and (
+ self.moy_rcue > sco_codes.BUT_BARRE_RCUE
+ )
+
+ def code_valide(self) -> Union[ApcValidationRCUE, None]:
+ "Si ce RCUE est ADM, CMP ou ADJ, la validation. Sinon, None"
+ validation = self.query_validations().first()
+ if (validation is not None) and (
+ validation.code in {sco_codes.ADM, sco_codes.ADJ, sco_codes.CMP}
+ ):
+ return validation
+ return None
+
+
+def find_rcues(
+ formsemestre: FormSemestre, ue: UniteEns, etud: Identite
+) -> list[RegroupementCoherentUE]:
+ """Les RCUE (niveau de compétence) à considérer pour cet étudiant dans
+ ce semestre pour cette UE.
+
+ Cherche les UEs du même niveau de compétence auxquelles l'étudiant est inscrit.
+ En cas de redoublement, il peut y en avoir plusieurs, donc plusieurs RCUEs.
+
+ Résultat: la liste peut être vide.
"""
if (ue.niveau_competence is None) or (ue.semestre_idx is None):
- return None, None
+ return []
- if ue.semestre_idx % 2:
+ if ue.semestre_idx % 2: # S1, S3, S5
other_semestre_idx = ue.semestre_idx + 1
else:
other_semestre_idx = ue.semestre_idx - 1
@@ -75,45 +216,38 @@ def get_other_ue_rcue(ue: UniteEns, etudid: int) -> tuple[UniteEns, FormSemestre
cursor = db.session.execute(
text(
"""SELECT
- ue.id, sem.id
+ ue.id, formsemestre.id
FROM
notes_ue ue,
notes_formsemestre_inscription inscr,
- notes_formsemestre sem
+ notes_formsemestre formsemestre
- WHERE
+ WHERE
inscr.etudid = :etudid
- AND inscr.formsemestre_id = sem.id
+ AND inscr.formsemestre_id = formsemestre.id
- AND sem.semestre_id = :other_semestre_idx
- AND ue.formation_id = sem.formation_id
+ AND formsemestre.semestre_id = :other_semestre_idx
+ AND ue.formation_id = formsemestre.formation_id
AND ue.niveau_competence_id = :ue_niveau_competence_id
AND ue.semestre_idx = :other_semestre_idx
"""
),
{
- "etudid": etudid,
+ "etudid": etud.id,
"other_semestre_idx": other_semestre_idx,
"ue_niveau_competence_id": ue.niveau_competence_id,
},
)
- r = cursor.fetchone()
- if r is None:
- return None, None
-
- return UniteEns.query.get(r[0]), FormSemestre.query.get(r[1])
-
- # q = UniteEns.query.filter(
- # FormSemestreInscription.etudid == etudid,
- # FormSemestreInscription.formsemestre_id == FormSemestre.id,
- # FormSemestre.formation_id == UniteEns.formation_id,
- # FormSemestre.semestre_id == UniteEns.semestre_idx,
- # UniteEns.niveau_competence_id == ue.niveau_competence_id,
- # UniteEns.semestre_idx != ue.semestre_idx,
- # )
- # if q.count() > 1:
- # log("Warning: get_other_ue_rcue: {q.count()} candidates UE")
- # return q.first()
+ rcues = []
+ for ue_id, formsemestre_id in cursor:
+ other_ue = UniteEns.query.get(ue_id)
+ other_formsemestre = FormSemestre.query.get(formsemestre_id)
+ rcues.append(
+ RegroupementCoherentUE(etud, formsemestre, ue, other_formsemestre, other_ue)
+ )
+ # safety check: 1 seul niveau de comp. concerné:
+ assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1
+ return rcues
class ApcValidationAnnee(db.Model):
@@ -134,6 +268,7 @@ class ApcValidationAnnee(db.Model):
formsemestre_id = db.Column(
db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True
)
+ "le semestre IMPAIR (le 1er) de l'année"
annee_scolaire = db.Column(db.Integer, nullable=False) # 2021
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
diff --git a/app/models/validations.py b/app/models/validations.py
index 64bdaef808aa856f2ddab31289d2f308d5c98b18..976e35f9f9b80ba87989028b06e034347eb416b4 100644
--- a/app/models/validations.py
+++ b/app/models/validations.py
@@ -54,7 +54,7 @@ class ScolarFormSemestreValidation(db.Model):
ue = db.relationship("UniteEns", lazy="select", uselist=False)
def __repr__(self):
- return f"{self.__class__.__name__}({self.formsemestre_id}, {self.etudid}, code={self.code}, ue={self.ue_id}, moy_ue={self.moy_ue})"
+ return f"{self.__class__.__name__}({self.formsemestre_id}, {self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"
class ScolarAutorisationInscription(db.Model):
diff --git a/app/scodoc/sco_codes_parcours.py b/app/scodoc/sco_codes_parcours.py
index e7ebe7bdce0b824d14ac9a3588040cd8f3afdc6c..b9e4943e54afa120b1e285799e56f769fd04bf0f 100644
--- a/app/scodoc/sco_codes_parcours.py
+++ b/app/scodoc/sco_codes_parcours.py
@@ -68,7 +68,8 @@ NOTES_TOLERANCE = 0.00499999999999 # si note >= (BARRE-TOLERANCE), considere ok
# (permet d'eviter d'afficher 10.00 sous barre alors que la moyenne vaut 9.999)
# Barre sur moyenne générale utilisée pour compensations semestres:
-NOTES_BARRE_GEN_COMPENSATION = 10.0 - NOTES_TOLERANCE
+NOTES_BARRE_GEN = 10.0
+NOTES_BARRE_GEN_COMPENSATION = NOTES_BARRE_GEN - NOTES_TOLERANCE
# ----------------------------------------------------------------
# Types d'UE:
@@ -192,6 +193,8 @@ CODES_UE_VALIDES = {ADM: True, CMP: True} # UE validée
# Pour le BUT:
CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL}
CODES_RCUE = {ADM, AJ, CMP}
+BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE
+BUT_RCUE_SUFFISANT = 8.0 - NOTES_TOLERANCE
def code_semestre_validant(code: str) -> bool:
diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py
index 436e1a916a4576056e643d3c3167bc45167a4de5..a0a4e1d42353143979628f5eaedbdeddecdb3057 100644
--- a/app/scodoc/sco_edit_ue.py
+++ b/app/scodoc/sco_edit_ue.py
@@ -927,7 +927,7 @@ def _html_select_semestre_idx(formation_id, semestre_ids, semestre_idx):
def _ue_table_ues(
parcours,
- ues,
+ ues: list[dict],
editable,
tag_editable,
has_perm_change,
@@ -936,7 +936,7 @@ def _ue_table_ues(
arrow_none,
delete_icon,
delete_disabled_icon,
-):
+) -> str:
"""Édition de programme: liste des UEs (avec leurs matières et modules).
Pour les formations classiques (non APC/BUT)
"""
@@ -964,9 +964,9 @@ def _ue_table_ues(
if ue["semestre_id"] == sco_codes_parcours.UE_SEM_DEFAULT:
lab = "Pas d'indication de semestre:"
else:
- lab = "Semestre %s:" % ue["semestre_id"]
+ lab = f"""Semestre {ue["semestre_id"]}:"""
H.append(
- '<div class="ue_list_div"><div class="ue_list_tit_sem">%s</div>' % lab
+ f'<div class="ue_list_div"><div class="ue_list_tit_sem">{lab}</div>'
)
H.append('<ul class="notes_ue_list">')
H.append('<li class="notes_ue_list">')
diff --git a/app/templates/pn/form_ues.html b/app/templates/pn/form_ues.html
index c603c6c522bce4ca0aeeb561368fd6c6d1a0d6c8..83648854944a700d61483aeae0e3fc33706f13f7 100644
--- a/app/templates/pn/form_ues.html
+++ b/app/templates/pn/form_ues.html
@@ -32,6 +32,8 @@
ue.color if ue.color is not none else 'blue'}}"></span>
<b>{{ue.acronyme}}</b> <a class="discretelink" href="{{
url_for('notes.ue_infos', scodoc_dept=g.scodoc_dept, ue_id=ue.id)}}"
+ title="{{ue.acronyme}}: {{'pas de compétence associée' if ue.niveau_competence is none
+ else 'compétence ' + ue.niveau_competence.annee + ' ' + ue.niveau_competence.competence.titre_long}}"
>{{ue.titre}}</a>
{% set virg = joiner(", ") %}
<span class="ue_code">(
diff --git a/app/views/notes.py b/app/views/notes.py
index bc5da600d132314758c15be2e7d97dc3dbcda796..8696614821c3c7faf080ed4d5df6bf45167256e4 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -31,6 +31,7 @@ Module notes: issu de ScoDoc7 / ZNotes.py
Emmanuel Viennet, 2021
"""
+import html
from operator import itemgetter
import time
from xml.etree import ElementTree
@@ -41,8 +42,11 @@ from flask import current_app, g, request
from flask_login import current_user
from werkzeug.utils import redirect
+from app.but import jury_but
from app.comp import res_sem
+from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat
+from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.models.formsemestre import FormSemestreUEComputationExpr
from app.models.modules import Module
@@ -2209,6 +2213,54 @@ def formsemestre_validation_etud_manu(
)
+# --- Jurys BUT
+@bp.route(
+ "/formsemestre_validation_but/<int:formsemestre_id>/<int:etudid>",
+ methods=["GET", "POST"],
+)
+@scodoc
+@permission_required(Permission.ScoView)
+def formsemestre_validation_but(formsemestre_id: int, etudid: int):
+ "Form. saisie décision jury semestre BUT"
+ if not sco_permissions_check.can_validate_sem(formsemestre_id):
+ return scu.confirm_dialog(
+ message="<p>Opération non autorisée pour %s</h2>" % current_user,
+ dest_url=url_for(
+ "notes.formsemestre_status",
+ scodoc_dept=g.scodoc_dept,
+ formsemestre_id=formsemestre_id,
+ ),
+ )
+ # XXX TODO Page expérimentale pour les devs
+ H = [
+ html_sco_header.sco_header(
+ page_title="Validation BUT", formsemestre_id=formsemestre_id, etudid=etudid
+ ),
+ f"""
+ <h2>XXX Experimental XXX</h2>
+ """,
+ ]
+ formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
+ etud = Identite.query.get_or_404(etudid)
+ res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
+ # ---- UEs
+ H.append(f"<ul>")
+ for ue in formsemestre.query_ues(): # volontairement toutes les UE
+ dec_proposee = jury_but.DecisionsProposeesUE(etud, formsemestre, ue)
+ H.append("<li>" + html.escape(f"""{ue} : {dec_proposee}""") + "</li>")
+ H.append(f"</ul>")
+
+ if formsemestre.semestre_id % 2 == 0:
+ # ---- RCUES
+ H.append(f"<ul>")
+ for ue in formsemestre.query_ues(): # volontairement toutes les UE
+ dec_proposee = jury_but.decisions_ue_proposees(etud, formsemestre, ue)
+ H.append("<li>" + html.escape(f"""{ue} : {dec_proposee}""") + "</li>")
+ H.append(f"</ul>")
+
+ return "\n".join(H) + html_sco_header.sco_footer()
+
+
@bp.route("/formsemestre_validate_previous_ue", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)