diff --git a/app/but/jury_but.py b/app/but/jury_but.py index b105bc7c..9997a736 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -1,1092 +1,1107 @@ -############################################################################## -# 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 = """
certaines UE du semestre pair ne sont pas associées à un niveau de compétence
""" - if all(u.niveau_competence for u in deca.ues_impair): - warning_impair = "" - else: - warning_impair = """
certaines UE du semestre impair ne sont pas associées à un niveau de compétence
""" - msg = ( - f"""

Pas de RCUE pour l'UE {ue.acronyme}

- {warning_impair} - {warning_pair} -
UE {ue.acronyme}: niveau {html.escape(str(ue.niveau_competence))}
-
UEs impaires: {html.escape(', '.join(str(u.niveau_competence or "pas de niveau") - for u in deca.ues_impair))} -
- """ - + 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_rcue_valides = len( - [rcue for rcue in rcues_avec_niveau if rcue.code_valide()] - ) - "le nombre de niveaux validés (déc. jury prise)" - self.nb_rcues_under_8 = len( - [rcue for rcue in rcues_avec_niveau if not rcue.est_suffisant()] - ) - "le nb de comp. sous la barre de 8/20" - # année ADM si toutes RCUE validées (sinon PASD) et non DEM ou DEF - self.admis = (self.nb_validables == self.nb_competences) and ( - self.inscription_etat == scu.INSCRIT - ) - "vrai si l'année est réussie, tous niveaux validables ou validés par le jury" - self.valide_moitie_rcue = self.nb_validables > (self.nb_competences // 2) - # 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, - sco_codes.PASD, # voir #488 (discutable, conventions locales) - ] + 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"""DecisionsProposeesAnnee - - """ - - 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 {html.escape(code)} 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, - ) - # log(f"built {self}") - 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 jury pour cette UE. - Si no_overwrite, n'enregistre pas s'il y a déjà un code. - """ - if code and not code in self.codes: - raise ScoValueError( - f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}" - ) - if code == self.code_valide or (self.code_valide is not None and no_overwrite): - self.recorded = True - return # no change - self.erase() - if code is None: - self.validation = None - 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}", - ) - log(f"DecisionsProposeesUE: recording {self.validation}") - 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: - log(f"DecisionsProposeesUE: deleting {validation}") - 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 +############################################################################## +# 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 = """
certaines UE du semestre pair ne sont pas associées à un niveau de compétence
""" + if all(u.niveau_competence for u in deca.ues_impair): + warning_impair = "" + else: + warning_impair = """
certaines UE du semestre impair ne sont pas associées à un niveau de compétence
""" + msg = ( + f"""

Pas de RCUE pour l'UE {ue.acronyme}

+ {warning_impair} + {warning_pair} +
UE {ue.acronyme}: niveau {html.escape(str(ue.niveau_competence))}
+
UEs impaires: {html.escape(', '.join(str(u.niveau_competence or "pas de niveau") + for u in deca.ues_impair))} +
+ """ + + 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_rcue_valides = len( + [rcue for rcue in rcues_avec_niveau if rcue.code_valide()] + ) + "le nombre de niveaux validés (déc. jury prise)" + self.nb_rcues_under_8 = len( + [rcue for rcue in rcues_avec_niveau if not rcue.est_suffisant()] + ) + "le nb de comp. sous la barre de 8/20" + # année ADM si toutes RCUE validées (sinon PASD) et non DEM ou DEF + self.admis = (self.nb_validables == self.nb_competences) and ( + self.inscription_etat == scu.INSCRIT + ) + "vrai si l'année est réussie, tous niveaux validables ou validés par le jury" + self.valide_moitie_rcue = self.nb_validables > (self.nb_competences // 2) + # 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, + sco_codes.PASD, # voir #488 (discutable, conventions locales) + ] + self.codes + self.explanation = ( + expl_rcues + + f""" et {self.nb_rcues_under_8} + niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8""" + ) + # Si l'un des semestres est extérieur, propose ADM + if ( + self.formsemestre_impair.modalite == "EXT" + or self.formsemestre_pair.modalite == "EXT" + ): + self.codes.insert(0, sco_codes.ADM) + + # + + def infos(self) -> str: + "informations, for debugging purpose" + return f"""DecisionsProposeesAnnee + + """ + + 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 {html.escape(code)} 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) + # Si au moins l'un des semestres est extérieur, propose ADM au cas où + if ( + dec_prop_annee.formsemestre_impair.modalite == "EXT" + or dec_prop_annee.formsemestre_pair.modalite == "EXT" + ): + self.codes.insert(0, sco_codes.ADM) + + 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, + ) + # log(f"built {self}") + 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) + ) or self.formsemestre.modalite == "EXT": + self.codes.insert(0, sco_codes.ADM) + self.explanation = (f"Moyenne >= {sco_codes.ParcoursBUT.BARRE_MOY}/20",) + elif self.rcue and self.rcue.est_compensable(): + self.codes.insert(0, sco_codes.CMP) + self.explanation = "compensable dans le RCUE" + else: + # Échec à valider cette UE + self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes + self.explanation = "notes insuffisantes" + + def record(self, code: str, no_overwrite=False): + """Enregistre le code jury pour cette UE. + Si no_overwrite, n'enregistre pas s'il y a déjà un code. + """ + if code and not code in self.codes: + raise ScoValueError( + f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}" + ) + if code == self.code_valide or (self.code_valide is not None and no_overwrite): + self.recorded = True + return # no change + self.erase() + if code is None: + self.validation = None + 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}", + ) + log(f"DecisionsProposeesUE: recording {self.validation}") + 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: + log(f"DecisionsProposeesUE: deleting {validation}") + 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 diff --git a/app/scodoc/sco_formsemestre_exterieurs.py b/app/scodoc/sco_formsemestre_exterieurs.py index ba0595ca..baec914d 100644 --- a/app/scodoc/sco_formsemestre_exterieurs.py +++ b/app/scodoc/sco_formsemestre_exterieurs.py @@ -1,527 +1,529 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""Saisie et gestion des semestres extérieurs à ScoDoc dans un parcours. - -On va créer/gérer des semestres de la même formation que le semestre ScoDoc -où est inscrit l'étudiant, leur attribuer la modalité 'EXT'. -Ces semestres n'auront qu'un seul inscrit ! -""" -import time - -import flask -from flask import url_for, g, request -from flask_login import current_user - -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import ( - FormSemestre, - FormSemestreUECoef, - Identite, - ScolarFormSemestreValidation, - UniteEns, -) -import app.scodoc.sco_utils as scu -from app import log -from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message -from app.scodoc import html_sco_header -from app.scodoc import sco_formations -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_inscriptions -from app.scodoc import sco_formsemestre_validation -from app.scodoc import sco_etud -from app.scodoc.sco_codes_parcours import UE_SPORT - - -def formsemestre_ext_create(etudid, sem_params): - """Crée un formsemestre exterieur et y inscrit l'étudiant. - sem_params: dict nécessaire à la création du formsemestre - """ - # Check args - _formation = sco_formations.formation_list( - args={"formation_id": sem_params["formation_id"]} - )[0] - if etudid: - _etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - - # Create formsemestre - sem_params["modalite"] = "EXT" - sem_params["etapes"] = None - sem_params["responsables"] = [current_user.id] - formsemestre_id = sco_formsemestre.do_formsemestre_create(sem_params, silent=True) - # nota: le semestre est créé vide: pas de modules - - # Inscription au semestre - sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules( - formsemestre_id, - etudid, - method="formsemestre_ext_create", - ) - return formsemestre_id - - -def formsemestre_ext_create_form(etudid, formsemestre_id): - """Formulaire création/inscription à un semestre extérieur""" - etud = Identite.query.get_or_404(etudid) - H = [ - html_sco_header.sco_header(), - f"""

Enregistrement d'une inscription antérieure dans un autre - établissement

-

- Cette opération crée un semestre extérieur ("ancien") de la même - formation que le semestre courant, et y inscrit juste cet étudiant. - La décision de jury peut ensuite y être saisie. -

-

- Notez que si un semestre extérieur similaire a déjà été créé pour un autre - étudiant, il est préférable d'utiliser la fonction - " - inscrire à un autre semestre" -

-

Étudiant {etud.nomprenom}

- """, - ] - F = html_sco_header.sco_footer() - orig_sem = sco_formsemestre.get_formsemestre(formsemestre_id) - # Ne propose que des semestres de semestre_id strictement inférieur - # au semestre courant - # et seulement si pas inscrit au même semestre_id d'un semestre ordinaire ScoDoc. - # Les autres situations (eg redoublements en changeant d'établissement) - # doivent être gérées par les validations de semestres "antérieurs" - insem = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - args={"etudid": etudid, "etat": "I"} - ) - semlist = [sco_formsemestre.get_formsemestre(i["formsemestre_id"]) for i in insem] - existing_semestre_ids = {s["semestre_id"] for s in semlist} - min_semestre_id = 1 - max_semestre_id = orig_sem["semestre_id"] - semestre_ids = set(range(min_semestre_id, max_semestre_id)) - existing_semestre_ids - H.append( - f"""

L'étudiant est déjà inscrit dans des semestres ScoDoc de rangs: - { sorted(list(existing_semestre_ids)) } -

- """ - ) - if not semestre_ids: - H.append( - f"""

pas de semestres extérieurs possibles - (indices entre {min_semestre_id} et {max_semestre_id}, semestre courant.) -

""" - ) - return "\n".join(H) + F - # Formulaire - semestre_ids_list = sorted(semestre_ids) - semestre_ids_labels = [f"S{x}" for x in semestre_ids_list] - descr = [ - ("formsemestre_id", {"input_type": "hidden"}), - ("etudid", {"input_type": "hidden"}), - ( - "semestre_id", - { - "input_type": "menu", - "title": "Indice du semestre dans le cursus", - "allowed_values": semestre_ids_list, - "labels": semestre_ids_labels, - }, - ), - ( - "titre", - { - "size": 40, - "title": "Nom de ce semestre extérieur", - "explanation": """par exemple: établissement. - N'indiquez pas les dates, ni le semestre, ni la modalité dans - le titre: ils seront automatiquement ajoutés""", - }, - ), - ( - "date_debut", - { - "title": "Date de début", # j/m/a - "input_type": "datedmy", - "explanation": "j/m/a (peut être approximatif)", - "size": 9, - "allow_null": False, - }, - ), - ( - "date_fin", - { - "title": "Date de fin", # j/m/a - "input_type": "datedmy", - "explanation": "j/m/a (peut être approximatif)", - "size": 9, - "allow_null": False, - }, - ), - ( - "elt_help_ue", - { - "title": """Les notes et coefficients des UE - capitalisées seront saisis ensuite""", - "input_type": "separator", - }, - ), - ] - - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - descr, - cancelbutton="Annuler", - method="post", - submitlabel="Créer semestre extérieur et y inscrire l'étudiant", - cssclass="inscription", - name="tf", - ) - if tf[0] == 0: - H.append( - """

Ce formulaire sert à enregistrer un semestre antérieur dans - la formation effectué dans un autre établissement. -

""" - ) - return "\n".join(H) + "\n" + tf[1] + F - elif tf[0] == -1: - return flask.redirect( - url_for( - "notes.formsemestre_bulletinetud", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - etudid=etudid, - ) - ) - else: - # Le semestre extérieur est créé dans la même formation que le semestre courant - tf[2]["formation_id"] = orig_sem["formation_id"] - formsemestre_ext_create(etudid, tf[2]) - return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - ) - - -def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid): - """Edition des validations d'UE et de semestre (jury) - pour un semestre extérieur. - On peut saisir pour chaque UE du programme de formation - sa validation, son code jury, sa note, son coefficient - (sauf en BUT où le coef. des UE est toujours égal aux ECTS). - - La moyenne générale indicative du semestre est calculée et affichée, - mais pas enregistrée. - """ - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) - etud = Identite.query.get_or_404(etudid) - ues = formsemestre.formation.ues.filter(UniteEns.type != UE_SPORT).order_by( - UniteEns.semestre_idx, UniteEns.numero - ) - descr = _ue_form_description(formsemestre, etud, ues, scu.get_request_args()) - initvalues = {} - if request.method == "GET": - for ue in ues: - validation = ScolarFormSemestreValidation.query.filter_by( - ue_id=ue.id, etudid=etud.id, formsemestre_id=formsemestre.id - ).first() - initvalues[f"note_{ue.id}"] = validation.moy_ue if validation else "" - - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - descr, - submitlabel="Enregistrer ces validations", - cancelbutton="Annuler", - initvalues=initvalues, - cssclass="tf_ext_edit_ue_validations ext_apc" - if formsemestre.formation.is_apc() - else "tf_ext_edit_ue_validations", - # En APC, stocke les coefficients pour l'affichage de la moyenne en direct - form_attrs=f"""data-ue_coefs='[{', '.join(str(ue.ects or 0) for ue in ues)}]'""" - if formsemestre.formation.is_apc() - else "", - ) - if tf[0] == -1: - return "

annulation

" - else: - H = _make_page(etud, formsemestre, tf) - if tf[0] == 0: # premier affichage - return "\n".join(H) - else: # soumission - # simule erreur - ok, message = _check_values(formsemestre, ues, tf[2]) - if not ok: - H = _make_page(etud, formsemestre, tf, message=message) - return "\n".join(H) - else: - # Submit - _record_ue_validations_and_coefs(formsemestre, etud, ues, tf[2]) - return flask.redirect( - url_for( - "notes.formsemestre_bulletinetud", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - etudid=etudid, - ) - ) - - -def _make_page(etud: Identite, formsemestre: FormSemestre, tf, message="") -> list[str]: - """html formulaire saisie""" - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - moy_gen = nt.get_etud_moy_gen(etud.id) - H = [ - html_sco_header.sco_header( - page_title="Validation des UE d'un semestre extérieur", - javascripts=["js/formsemestre_ext_edit_ue_validations.js"], - ), - tf_error_message(message), - f"""

{etud.nomprenom} est inscrit{etud.e} à ce semestre extérieur.

-

Voici ses UE enregistrées avec leur notes - { "et coefficients" if not formsemestre.formation.is_apc() - else " (en BUT, les coefficients sont égaux aux ECTS)"}. -

- """, - f"""

La moyenne de ce semestre serait: - {moy_gen} / 20 -

- """, - '
', - tf[1], - "
", - f"""
- retour au bulletin de notes -
- """, - html_sco_header.sco_footer(), - ] - return H - - -_UE_VALID_CODES = { - None: "Non inscrit", - "ADM": "Capitalisée (ADM)", - # "CMP": "Acquise (car semestre validé)", -} - - -def _ue_form_description( - formsemestre: FormSemestre, etud: Identite, ues: list[UniteEns], values -): - """Description du formulaire de saisie des UE / validations - Pour chaque UE, on peut saisir: son code jury, sa note, son coefficient. - """ - descr = [ - ( - "head_sep", - { - "input_type": "separator", - "template": """UE - Code juryNote/20 - """ - + ( - """Coefficient UE""" - if not formsemestre.formation.is_apc() - else "" - ) - + "", - }, - ), - ("formsemestre_id", {"input_type": "hidden"}), - ("etudid", {"input_type": "hidden"}), - ] - for ue in ues: - # Menu pour code validation UE: - # Ne propose que ADM, CMP et "Non inscrit" - select_name = f"valid_{ue.id}" - menu_code_ue = f"""" - if formsemestre.formation.is_apc(): - coef_disabled = 'disabled="1"' - cur_coef_value = ue.ects or 0 - coef_input_class = "ext_coef_disabled" - else: - cur_coef_value = values.get(f"coef_{ue.id}", False) - coef_input_class = "" - if cur_coef_value is False: # pas dans le form, cherche en base - ue_coef: FormSemestreUECoef = FormSemestreUECoef.query.filter_by( - formsemestre_id=formsemestre.id, ue_id=ue.id - ).first() - cur_coef_value = (ue_coef.coefficient if ue_coef else "") or "" - itemtemplate = ( - f""" - - %(label)s - { menu_code_ue } - %(elem)s - """ - + ( - f""" - - """ - if not formsemestre.formation.is_apc() - else "" - ) - + """""" - ) - - descr.append( - ( - f"note_{ue.id}", - { - "input_type": "text", - "size": 4, - "template": itemtemplate, - "title": "" - + (f"S{ue.semestre_idx} " if ue.semestre_idx is not None else "") - + f"{ue.acronyme} {ue.titre}" - + f" ({ue.ects} ECTS)" - if ue.ects is not None - else "", - "attributes": [coef_disabled], - }, - ) - ) - return descr - - -def _check_values(formsemestre: FormSemestre, ue_list, values): - """Check that form values are ok - for each UE: - code != None => note and coef - note or coef => code != None - note float in [0, 20] - note => coef - coef float >= 0 - """ - for ue in ue_list: - pu = f" pour UE {ue.acronyme}" - code = values.get(f"valid_{ue.id}", False) - if code == "None": - code = None - note = values.get(f"note_{ue.id}", False) - try: - note = _convert_field_to_float(note) - except ValueError: - return False, "note invalide" + pu - - if code is not False: - if code not in _UE_VALID_CODES: - return False, "code invalide" + pu - if code is not None: - if note is False or note == "": - return False, "note manquante" + pu - coef = values.get(f"coef_{ue.id}", False) - try: - coef = _convert_field_to_float(coef) - except ValueError: - return False, "coefficient invalide" + pu - if note is not False and note != "": - if code is None: - return ( - False, - f"""code jury incohérent (code {code}, note {note}) {pu} - (supprimer note)""", - ) - if note < 0 or note > 20: - return False, "valeur note invalide" + pu - if not isinstance(coef, float) and not formsemestre.formation.is_apc(): - return False, f"coefficient manquant pour note {note} {pu}" - - # Vérifie valeur coef seulement pour formations classiques: - if not formsemestre.formation.is_apc(): - if coef is not False and coef != "": - if coef < 0: - return False, "valeur coefficient invalide" + pu - - return True, "ok" - - -def _convert_field_to_float(val): - """val may be empty, False (left unchanged), or a float. Raise exception ValueError""" - if val is not False: - val = val.strip() - if val: - val = float(val) - return val - - -def _record_ue_validations_and_coefs( - formsemestre: FormSemestre, etud: Identite, ues: list[UniteEns], values -): - """Enregistre en base les validations - En APC, le coef est toujours NULL - """ - for ue in ues: - code = values.get(f"valid_{ue.id}", False) - if code == "None": - code = None - note = values.get(f"note_{ue.id}", False) - note = _convert_field_to_float(note) - coef = values.get(f"coef_{ue.id}", False) - coef = _convert_field_to_float(coef) - if coef == "" or coef is False: - coef = None - now_dmy = time.strftime("%d/%m/%Y") - log( - f"_record_ue_validations_and_coefs: {formsemestre.id} etudid={etud.id} ue_id={ue.id} moy_ue={note} ue_coef={coef}" - ) - assert code is None or (note) # si code validant, il faut une note - sco_formsemestre_validation.do_formsemestre_validate_previous_ue( - formsemestre.id, - etud.id, - ue.id, - note, - now_dmy, - code=code, - ue_coefficient=coef, - ) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Saisie et gestion des semestres extérieurs à ScoDoc dans un parcours. + +On va créer/gérer des semestres de la même formation que le semestre ScoDoc +où est inscrit l'étudiant, leur attribuer la modalité 'EXT'. +Ces semestres n'auront qu'un seul inscrit ! +""" +import time + +import flask +from flask import url_for, g, request +from flask_login import current_user + +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import ( + FormSemestre, + FormSemestreUECoef, + Identite, + ScolarFormSemestreValidation, + UniteEns, +) +import app.scodoc.sco_utils as scu +from app import log +from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message +from app.scodoc import html_sco_header +from app.scodoc import sco_formations +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formsemestre_inscriptions +from app.scodoc import sco_formsemestre_validation +from app.scodoc import sco_etud +from app.scodoc.sco_codes_parcours import UE_SPORT + + +def formsemestre_ext_create(etudid, sem_params): + """Crée un formsemestre exterieur et y inscrit l'étudiant. + sem_params: dict nécessaire à la création du formsemestre + """ + # Check args + _formation = sco_formations.formation_list( + args={"formation_id": sem_params["formation_id"]} + )[0] + if etudid: + _etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + + # Create formsemestre + sem_params["modalite"] = "EXT" + sem_params["etapes"] = None + sem_params["responsables"] = [current_user.id] + formsemestre_id = sco_formsemestre.do_formsemestre_create(sem_params, silent=True) + # nota: le semestre est créé vide: pas de modules + + # Inscription au semestre + sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules( + formsemestre_id, + etudid, + method="formsemestre_ext_create", + ) + return formsemestre_id + + +def formsemestre_ext_create_form(etudid, formsemestre_id): + """Formulaire création/inscription à un semestre extérieur""" + etud = Identite.query.get_or_404(etudid) + H = [ + html_sco_header.sco_header(), + f"""

Enregistrement d'une inscription antérieure dans un autre + établissement

+

+ Cette opération crée un semestre extérieur ("ancien") de la même + formation que le semestre courant, et y inscrit juste cet étudiant. + La décision de jury peut ensuite y être saisie. +

+

+ Notez que si un semestre extérieur similaire a déjà été créé pour un autre + étudiant, il est préférable d'utiliser la fonction + " + inscrire à un autre semestre" +

+

Étudiant {etud.nomprenom}

+ """, + ] + F = html_sco_header.sco_footer() + orig_sem = sco_formsemestre.get_formsemestre(formsemestre_id) + # Ne propose que des semestres de semestre_id strictement inférieur + # au semestre courant + # et seulement si pas inscrit au même semestre_id d'un semestre ordinaire ScoDoc. + # Les autres situations (eg redoublements en changeant d'établissement) + # doivent être gérées par les validations de semestres "antérieurs" + insem = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + args={"etudid": etudid, "etat": "I"} + ) + semlist = [sco_formsemestre.get_formsemestre(i["formsemestre_id"]) for i in insem] + existing_semestre_ids = {s["semestre_id"] for s in semlist} + min_semestre_id = 1 + max_semestre_id = orig_sem["semestre_id"] + semestre_ids = set(range(min_semestre_id, max_semestre_id)) - existing_semestre_ids + H.append( + f"""

L'étudiant est déjà inscrit dans des semestres ScoDoc de rangs: + { sorted(list(existing_semestre_ids)) } +

+ """ + ) + if not semestre_ids: + H.append( + f"""

pas de semestres extérieurs possibles + (indices entre {min_semestre_id} et {max_semestre_id}, semestre courant.) +

""" + ) + return "\n".join(H) + F + # Formulaire + semestre_ids_list = sorted(semestre_ids) + semestre_ids_labels = [f"S{x}" for x in semestre_ids_list] + descr = [ + ("formsemestre_id", {"input_type": "hidden"}), + ("etudid", {"input_type": "hidden"}), + ( + "semestre_id", + { + "input_type": "menu", + "title": "Indice du semestre dans le cursus", + "allowed_values": semestre_ids_list, + "labels": semestre_ids_labels, + }, + ), + ( + "titre", + { + "size": 40, + "title": "Nom de ce semestre extérieur", + "explanation": """par exemple: établissement. + N'indiquez pas les dates, ni le semestre, ni la modalité dans + le titre: ils seront automatiquement ajoutés""", + }, + ), + ( + "date_debut", + { + "title": "Date de début", # j/m/a + "input_type": "datedmy", + "explanation": "j/m/a (peut être approximatif)", + "size": 9, + "allow_null": False, + }, + ), + ( + "date_fin", + { + "title": "Date de fin", # j/m/a + "input_type": "datedmy", + "explanation": "j/m/a (peut être approximatif)", + "size": 9, + "allow_null": False, + }, + ), + ( + "elt_help_ue", + { + "title": """Les notes et coefficients des UE + capitalisées seront saisis ensuite""", + "input_type": "separator", + }, + ), + ] + + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + descr, + cancelbutton="Annuler", + method="post", + submitlabel="Créer semestre extérieur et y inscrire l'étudiant", + cssclass="inscription", + name="tf", + ) + if tf[0] == 0: + H.append( + """

Ce formulaire sert à enregistrer un semestre antérieur dans + la formation effectué dans un autre établissement. +

""" + ) + return "\n".join(H) + "\n" + tf[1] + F + elif tf[0] == -1: + return flask.redirect( + url_for( + "notes.formsemestre_bulletinetud", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + etudid=etudid, + ) + ) + else: + # Le semestre extérieur est créé dans la même formation que le semestre courant + tf[2]["formation_id"] = orig_sem["formation_id"] + formsemestre_ext_create(etudid, tf[2]) + return flask.redirect( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + ) + + +def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid): + """Edition des validations d'UE et de semestre (jury) + pour un semestre extérieur. + On peut saisir pour chaque UE du programme de formation + sa validation, son code jury, sa note, son coefficient + (sauf en BUT où le coef. des UE est toujours égal aux ECTS). + + La moyenne générale indicative du semestre est calculée et affichée, + mais pas enregistrée. + """ + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + etud = Identite.query.get_or_404(etudid) + ues = formsemestre.formation.ues.filter(UniteEns.type != UE_SPORT).order_by( + UniteEns.semestre_idx, UniteEns.numero + ) + if formsemestre.formation.is_apc(): + ues = ues.filter_by(semestre_idx=formsemestre.semestre_id) + descr = _ue_form_description(formsemestre, etud, ues, scu.get_request_args()) + initvalues = {} + if request.method == "GET": + for ue in ues: + validation = ScolarFormSemestreValidation.query.filter_by( + ue_id=ue.id, etudid=etud.id, formsemestre_id=formsemestre.id + ).first() + initvalues[f"note_{ue.id}"] = validation.moy_ue if validation else "" + + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + descr, + submitlabel="Enregistrer ces validations", + cancelbutton="Annuler", + initvalues=initvalues, + cssclass="tf_ext_edit_ue_validations ext_apc" + if formsemestre.formation.is_apc() + else "tf_ext_edit_ue_validations", + # En APC, stocke les coefficients pour l'affichage de la moyenne en direct + form_attrs=f"""data-ue_coefs='[{', '.join(str(ue.ects or 0) for ue in ues)}]'""" + if formsemestre.formation.is_apc() + else "", + ) + if tf[0] == -1: + return "

annulation

" + else: + H = _make_page(etud, formsemestre, tf) + if tf[0] == 0: # premier affichage + return "\n".join(H) + else: # soumission + # simule erreur + ok, message = _check_values(formsemestre, ues, tf[2]) + if not ok: + H = _make_page(etud, formsemestre, tf, message=message) + return "\n".join(H) + else: + # Submit + _record_ue_validations_and_coefs(formsemestre, etud, ues, tf[2]) + return flask.redirect( + url_for( + "notes.formsemestre_bulletinetud", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + etudid=etudid, + ) + ) + + +def _make_page(etud: Identite, formsemestre: FormSemestre, tf, message="") -> list[str]: + """html formulaire saisie""" + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + moy_gen = nt.get_etud_moy_gen(etud.id) + H = [ + html_sco_header.sco_header( + page_title="Validation des UE d'un semestre extérieur", + javascripts=["js/formsemestre_ext_edit_ue_validations.js"], + ), + tf_error_message(message), + f"""

{etud.nomprenom} est inscrit{etud.e} à ce semestre extérieur.

+

Voici ses UE enregistrées avec leur notes + { "et coefficients" if not formsemestre.formation.is_apc() + else " (en BUT, les coefficients sont égaux aux ECTS)"}. +

+ """, + f"""

La moyenne de ce semestre serait: + {moy_gen} / 20 +

+ """, + '
', + tf[1], + "
", + f"""
+ retour au bulletin de notes +
+ """, + html_sco_header.sco_footer(), + ] + return H + + +_UE_VALID_CODES = { + None: "Non inscrit", + "ADM": "Capitalisée (ADM)", + # "CMP": "Acquise (car semestre validé)", +} + + +def _ue_form_description( + formsemestre: FormSemestre, etud: Identite, ues: list[UniteEns], values +): + """Description du formulaire de saisie des UE / validations + Pour chaque UE, on peut saisir: son code jury, sa note, son coefficient. + """ + descr = [ + ( + "head_sep", + { + "input_type": "separator", + "template": """UE + Code juryNote/20 + """ + + ( + """Coefficient UE""" + if not formsemestre.formation.is_apc() + else "" + ) + + "", + }, + ), + ("formsemestre_id", {"input_type": "hidden"}), + ("etudid", {"input_type": "hidden"}), + ] + for ue in ues: + # Menu pour code validation UE: + # Ne propose que ADM, CMP et "Non inscrit" + select_name = f"valid_{ue.id}" + menu_code_ue = f"""" + if formsemestre.formation.is_apc(): + coef_disabled = 'disabled="1"' + cur_coef_value = ue.ects or 0 + coef_input_class = "ext_coef_disabled" + else: + cur_coef_value = values.get(f"coef_{ue.id}", False) + coef_input_class = "" + if cur_coef_value is False: # pas dans le form, cherche en base + ue_coef: FormSemestreUECoef = FormSemestreUECoef.query.filter_by( + formsemestre_id=formsemestre.id, ue_id=ue.id + ).first() + cur_coef_value = (ue_coef.coefficient if ue_coef else "") or "" + itemtemplate = ( + f""" + + %(label)s + { menu_code_ue } + %(elem)s + """ + + ( + f""" + + """ + if not formsemestre.formation.is_apc() + else "" + ) + + """""" + ) + + descr.append( + ( + f"note_{ue.id}", + { + "input_type": "text", + "size": 4, + "template": itemtemplate, + "title": "" + + (f"S{ue.semestre_idx} " if ue.semestre_idx is not None else "") + + f"{ue.acronyme} {ue.titre}" + + f" ({ue.ects} ECTS)" + if ue.ects is not None + else "", + "attributes": [coef_disabled], + }, + ) + ) + return descr + + +def _check_values(formsemestre: FormSemestre, ue_list, values): + """Check that form values are ok + for each UE: + code != None => note and coef + note or coef => code != None + note float in [0, 20] + note => coef + coef float >= 0 + """ + for ue in ue_list: + pu = f" pour UE {ue.acronyme}" + code = values.get(f"valid_{ue.id}", False) + if code == "None": + code = None + note = values.get(f"note_{ue.id}", False) + try: + note = _convert_field_to_float(note) + except ValueError: + return False, "note invalide" + pu + + if code is not False: + if code not in _UE_VALID_CODES: + return False, "code invalide" + pu + if code is not None: + if note is False or note == "": + return False, "note manquante" + pu + coef = values.get(f"coef_{ue.id}", False) + try: + coef = _convert_field_to_float(coef) + except ValueError: + return False, "coefficient invalide" + pu + if note is not False and note != "": + if code is None: + return ( + False, + f"""code jury incohérent (code {code}, note {note}) {pu} + (supprimer note)""", + ) + if note < 0 or note > 20: + return False, "valeur note invalide" + pu + if not isinstance(coef, float) and not formsemestre.formation.is_apc(): + return False, f"coefficient manquant pour note {note} {pu}" + + # Vérifie valeur coef seulement pour formations classiques: + if not formsemestre.formation.is_apc(): + if coef is not False and coef != "": + if coef < 0: + return False, "valeur coefficient invalide" + pu + + return True, "ok" + + +def _convert_field_to_float(val): + """val may be empty, False (left unchanged), or a float. Raise exception ValueError""" + if val is not False: + val = val.strip() + if val: + val = float(val) + return val + + +def _record_ue_validations_and_coefs( + formsemestre: FormSemestre, etud: Identite, ues: list[UniteEns], values +): + """Enregistre en base les validations + En APC, le coef est toujours NULL + """ + for ue in ues: + code = values.get(f"valid_{ue.id}", False) + if code == "None": + code = None + note = values.get(f"note_{ue.id}", False) + note = _convert_field_to_float(note) + coef = values.get(f"coef_{ue.id}", False) + coef = _convert_field_to_float(coef) + if coef == "" or coef is False: + coef = None + now_dmy = time.strftime("%d/%m/%Y") + log( + f"_record_ue_validations_and_coefs: {formsemestre.id} etudid={etud.id} ue_id={ue.id} moy_ue={note} ue_coef={coef}" + ) + assert code is None or (note) # si code validant, il faut une note + sco_formsemestre_validation.do_formsemestre_validate_previous_ue( + formsemestre.id, + etud.id, + ue.id, + note, + now_dmy, + code=code, + ue_coefficient=coef, + ) diff --git a/app/templates/scodoc/help/modules.html b/app/templates/scodoc/help/modules.html index b1382d5d..aabce645 100644 --- a/app/templates/scodoc/help/modules.html +++ b/app/templates/scodoc/help/modules.html @@ -1,50 +1,52 @@ -{# -*- mode: jinja-html -*- #} -
-

- Les modules sont décrits dans le programme pédagogique. Un module est pour ce - logiciel l'unité pédagogique élémentaire. On va lui associer une note - à travers des évaluations.
- Cette note (moyenne de module) sera utilisée pour calculer la moyenne - générale (et la moyenne de l'UE à laquelle appartient le module). Pour - cela, on utilisera le coefficient associé au module. -

- -

Un module possède un enseignant responsable - (typiquement celui qui dispense le cours magistral). On peut associer - au module une liste d'enseignants (typiquement les chargés de TD). - Tous ces enseignants, et le responsable du semestre, pourront - saisir et modifier les notes de ce module. -

- {%if is_apc%} -

- Dans le BUT, les modules peuvent être de type "ressource" ou "Situation - d'apprentissage et d'évaluation" (SAÉ). Ne pas oublier de préciser le - type, et de saisir les coefficients pondérant l'influence de la - ressource ou SAÉ vers les Unités d'Enseignement (UE). - Voir les détails sur - la documentation. -

- {%endif%} -
- -{% if formsemestres %} -
Module déjà utilisé dans des semestres, soyez prudents ! -

- Ce module est utilisé dans des semestres déjà mis en place, il faut prêter attention - aux conséquences des changements effectués ici: par exemple les coefficients vont modifier - les notes moyennes calculées. Les modules déjà utilisés ne peuvent pas être changés de semestre, ni détruits. - Si vous souhaitez faire cela, allez d'abord modifier les semestres concernés pour déselectionner le module. -

-

Semestres utilisant ce module:

- -
-{%endif%} - +{# -*- mode: jinja-html -*- #} +
+

+ Les modules sont décrits dans le programme pédagogique. Un module est pour ce + logiciel l'unité pédagogique élémentaire. On va lui associer une note + à travers des évaluations.
+ Cette note (moyenne de module) sera utilisée pour calculer la moyenne + générale (et la moyenne de l'UE à laquelle appartient le module). Pour + cela, on utilisera le coefficient associé au module. +

+ +

Un module possède un enseignant responsable + (typiquement celui qui dispense le cours magistral). On peut associer + au module une liste d'enseignants (typiquement les chargés de TD). + Tous ces enseignants, et le responsable du semestre, pourront + saisir et modifier les notes de ce module. +

+ {%if is_apc%} +

+ Dans le BUT, les modules peuvent être de type "ressource" ou "Situation + d'apprentissage et d'évaluation" (SAÉ). Ne pas oublier de préciser le + type, et de saisir les coefficients pondérant l'influence de la + ressource ou SAÉ vers les Unités d'Enseignement (UE). + Voir les détails sur + la documentation. +

+ {%endif%} +
+ +{% if formsemestres %} +
Module déjà utilisé dans des semestres, soyez prudents ! +

+ Ce module est utilisé dans des semestres déjà mis en place, il faut prêter attention + aux conséquences des changements effectués ici: par exemple les coefficients vont modifier + les notes moyennes calculées. Les modules déjà utilisés ne peuvent pas être changés de semestre, ni détruits. + Si vous souhaitez faire cela, allez d'abord modifier les semestres concernés pour déselectionner le module. +

+

Semestres utilisant ce module:

+ +
+{% else %} +

(module actuellement non utilisé dans les semestres)

+{%endif%} +