############################################################################## # ScoDoc # Copyright (c) 1999 - 2022 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 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 RED, UE_STANDARD from app.scodoc import sco_utils as scu from app.scodoc.sco_exceptions import ScoException, 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 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.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, ): super().__init__(etud=etud) self.formsemestre_id = formsemestre.id 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)" formsemestre_last = formsemestre_pair or formsemestre_impair "le formsemestre le plus avancé dans cette année" 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" self.inscription_etat = etud.inscription_etat(formsemestre_last.id) 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 ) 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 ) 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 ) self.niveaux_competences = ApcNiveau.niveaux_annee_de_parcours( self.parcour, self.annee_but, formation.referentiel_competence ).all() # non triés "liste des niveaux de compétences associés à cette année" 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_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" self.valide_moitie_rcue = self.nb_validables > (self.nb_competences // 2) # Peut passer si plus de la moitié validables et tous > 8 self.passage_de_droit = self.valide_moitie_rcue and (self.nb_rcues_under_8 == 0) # 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 expl_rcues = ( f"{self.nb_validables} niveau validable(s) sur {self.nb_competences}" ) if self.admis: self.codes = [sco_codes.ADM] + self.codes self.explanation = expl_rcues 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, ] elif self.passage_de_droit: self.codes = [sco_codes.PASD, sco_codes.ADJ] + self.codes self.explanation = expl_rcues 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 self.explanation = 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, ] + self.codes self.explanation = ( expl_rcues + f""" et {self.nb_rcues_under_8} niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8""" ) # def infos(self) -> str: "informations, for debugging purpose" return 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> <li>formsemestre_impair: <a href="{url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre_impair.id) }">{html.escape(str(self.formsemestre_impair))}</a> <ul> <li>Formation: <a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, semestre_idx=self.formsemestre_impair.semestre_id, formation_id=self.formsemestre_impair.formation.id)}"> {self.formsemestre_impair.formation.to_html()} ({self.formsemestre_impair.formation.id})</a> </li> </ul> </li> <li>formsemestre_pair: <a href="{url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre_pair.id) }">{html.escape(str(self.formsemestre_pair))}</a> <ul> <li>Formation: <a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, semestre_idx=self.formsemestre_pair.semestre_id, formation_id=self.formsemestre_pair.formation.id)}"> {self.formsemestre_pair.formation.to_html()} ({self.formsemestre_pair.formation.id})</a> </li> </ul> </li> <li>RCUEs: {html.escape(str(self.rcues_annee))}</li> <li>nb_competences: {getattr(self, "nb_competences", "-")}</li> <li>nb_nb_validables: {getattr(self, "nb_validables", "-")}</li> <li>codes: {self.codes}</li> <li>explanation: {self.explanation}</li> </ul> """ 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 de l'année scolaire à laquelle appartient formsemestre.""" 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 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, 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: formation: Formation = formsemestre.formation # 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 = [ue for ue in res.etud_ues(etudid) if ue.type == UE_STANDARD] ues.sort(key=lambda u: u.numero) 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) .order_by(UniteEns.numero) .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, self.inscription_etat, ) ues_impair_sans_rcue.discard(ue_impair.id) break if rcue is None: raise NoRCUEError(deca=self, ue=ue_pair) rcues_annee.append(rcue) if len(ues_impair_sans_rcue) > 0: 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). 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 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") with sco_cache.DeferredSemCacheManager(): 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}") dec_ue.record(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}") dec_rcue.record(code) elif key == "code_annee": # Code annuel self.record(code) 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é. """ 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.flush() 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, ) Scolog.logdb( method="jury_but", etudid=self.etud.id, msg=f"Validation année BUT{self.annee_but}: {code}", ) db.session.add(self.validation) # --- 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 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): """Enregistre les codes qui n'ont pas été spécifiés par le formulaire, et sont donc en mode "automatique" """ decisions = ( list(self.decisions_ues.values()) + list(self.decisions_rcue_by_niveau.values()) + [self] ) for dec in decisions: if not dec.recorded: # rappel: le code par défaut est en tête code = dec.codes[0] if dec.codes else None # s'il n'y a pas de code, efface dec.record(code, no_overwrite=True) def erase(self): """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. """ 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) db.session.flush() 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) 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.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) def record(self, code: str, no_overwrite=False): """Enregistre le 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 parcours_id = self.parcour.id if self.parcour is not None else None if self.validation: db.session.delete(self.validation) db.session.flush() 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, ) Scolog.logdb( method="jury_but", etudid=self.etud.id, msg=f"Validation RCUE {repr(self.rcue)}", ) db.session.add(self.validation) 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.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: 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, 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.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) self.validation = ScolarFormSemestreValidation.query.filter_by( etudid=etud.id, formsemestre_id=formsemestre.id, ue_id=ue.id ).first() super().__init__( etud=etud, code_valide=self.validation.code if self.validation is not None else None, ) 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" 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 ] self.moy_ue = np.NaN 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 self.moy_ue = res.etud_moy_ue[ue.id][etud.id] def set_rcue(self, rcue: RegroupementCoherentUE): """Rattache cette UE à un RCUE. Cela peut modifier les codes proposés (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): 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""" 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 if self.validation: db.session.delete(self.validation) db.session.flush() if code is None: self.validation = None 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, ) Scolog.logdb( method="jury_but", etudid=self.etud.id, msg=f"Validation UE {self.ue.id}", ) db.session.add(self.validation) sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id) 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: db.session.delete(validation) db.session.flush() 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 ScoException("BUTCursusEtud: pas de référentiel de compétences") 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