diff --git a/app/but/jury_but.py b/app/but/jury_but.py index fa956c86..4fd87833 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 - - -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 +from app.scodoc.sco_exceptions import ScoException, ScoValueError class DecisionsProposees: - # Codes toujours proposés sauf si include_communs est faux: - codes_communs = [codes.RAT, codes.DEF, codes.ABAN, codes.DEM, codes.UEBSL] + """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. - def __init__(self, code: str = None, explanation="", include_communs=True): + validation : None ou une instance de 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 - 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: +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.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 + ) + ) + ) + + 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()] + ) + self.nb_rcues_under_8 = len( + [rcue for rcue in self.rcues_annee if not rcue.est_suffisant()] + ) + # 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 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 - cette UE de cet étudiant dans ce semestre. + le RCUE de cet étudiant dans cette année. - si DEF ou DEM ou ABAN ou ABL sur année BUT: seulement DEF, DEM, ABAN, ABL + ADM, CMP, ADJ, AJ, RAT, DEF, ABAN + """ + + 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 + + et proposer toujours: RAT, DEF, ABAN, DEM, UEBSL (codes_communs) """ - 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, - ) - # 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", - ) - # 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 - ) - 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", - ) - return DecisionsProposees( - code=[codes.AJ, codes.ADJ], - explanation="notes insuffisantes", - ) + + # 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" -def decisions_rcue_proposees( - etud: Identite, - formsemestre_1: FormSemestre, - ue_1: UniteEns, - formsemestre_2: FormSemestre, - ue_2: UniteEns, -) -> DecisionsProposees: - """Liste des codes de décisions que l'on peut proposer pour - le RCUE de cet étudiant dans ces semestres. - - 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. - """ - # - - -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 0ea2e2ea..b139a6ab 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 7d58ace4..294d61a0 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 64bdaef8..976e35f9 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 e7ebe7bd..b9e4943e 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 436e1a91..a0a4e1d4 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( - '
%s
' % lab + f'
{lab}
' ) H.append('