diff --git a/app/but/jury_but.py b/app/but/jury_but.py new file mode 100644 index 000000000..fa956c866 --- /dev/null +++ b/app/but/jury_but.py @@ -0,0 +1,245 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Jury BUT: logique de gestion +""" +from operator import attrgetter + +from app.comp.res_but import ResultatsSemestreBUT +from app.comp import res_sem +from app.models import but_validations +from app.models.but_refcomp import ( + ApcAnneeParcours, + ApcCompetence, + ApcNiveau, + ApcParcoursNiveauCompetence, +) +from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE +from app.models.etudiants import Identite +from app.models.formations import Formation +from app.models.formsemestre import FormSemestre +from app.models.ues import UniteEns +from app.scodoc import sco_codes_parcours as codes +from app.scodoc import sco_utils as scu +from app.scodoc.sco_exceptions import ScoException + + +class RegroupementCoherentUE: + def __init__( + self, + etud: Identite, + formsemestre_1: FormSemestre, + ue_1: UniteEns, + formsemestre_2: FormSemestre, + ue_2: UniteEns, + ): + self.formsemestre_1 = formsemestre_1 + self.ue_1 = ue_1 + self.formsemestre_2 = formsemestre_2 + self.ue_2 = ue_2 + # stocke les moyennes d'UE + res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_1) + if ue_1.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_1.id]: + self.moy_ue_1 = res.etud_moy_ue[ue_1.id][etud.id] + else: + self.moy_ue_1 = None + res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_2) + if ue_2.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_2.id]: + self.moy_ue_2 = res.etud_moy_ue[ue_1.id][etud.id] + else: + self.moy_ue_2 = None + # Calcul de la moyenne au RCUE + if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None): + # Moyenne RCUE non pondérée (pour le moment) + self.moy_rcue = (self.moy_ue_1 + self.moy_ue_2) / 2 + else: + self.moy_rcue = None + + +class DecisionsProposees: + # Codes toujours proposés sauf si include_communs est faux: + codes_communs = [codes.RAT, codes.DEF, codes.ABAN, codes.DEM, codes.UEBSL] + + def __init__(self, code: str = None, explanation="", include_communs=True): + if include_communs: + self.codes = self.codes_communs + else: + self.codes = [] + if isinstance(code, list): + self.codes = code + self.codes_communs + elif code is not None: + self.codes = [code] + self.codes_communs + self.explanation = explanation + + def __repr__(self) -> str: + return f"""<{self.__class__.__name__} codes={self.codes} explanation={self.explanation}""" + + +def decisions_ue_proposees( + etud: Identite, formsemestre: FormSemestre, ue: UniteEns +) -> DecisionsProposees: + """Liste des codes de décisions que l'on peut proposer pour + cette UE de cet étudiant dans ce semestre. + + si DEF ou DEM ou ABAN ou ABL sur année BUT: seulement DEF, DEM, ABAN, ABL + + si moy_ue > 10, ADM + sinon si compensation dans RCUE: CMP + sinon: ADJ, AJ + et proposer toujours: RAT, DEF, ABAN, DEM, UEBSL + """ + if ue.type == codes.UE_SPORT: + return DecisionsProposees( + explanation="UE bonus, pas de décision de jury", include_communs=False + ) + # Code sur année ? + decision_annee = ApcValidationAnnee.query.filter_by( + etudid=etud.id, annee_scolaire=formsemestre.annee_scolaire() + ).first() + if ( + decision_annee is not None and decision_annee.code in codes.CODES_ANNEE_ARRET + ): # DEF, DEM, ABAN, ABL + return DecisionsProposees( + code=decision_annee.code, + explanation=f"l'année a le code {decision_annee.code}", + include_communs=False, + ) + # Moyenne de l'UE ? + res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) + if not ue.id in res.etud_moy_ue: + return DecisionsProposees(explanation="UE sans résultat") + if not etud.id in res.etud_moy_ue[ue.id]: + return DecisionsProposees(explanation="Étudiant sans résultat dans cette UE") + moy_ue = res.etud_moy_ue[ue.id][etud.id] + if moy_ue > (codes.ParcoursBUT.BARRE_MOY - codes.NOTES_TOLERANCE): + return DecisionsProposees( + code=codes.ADM, + explanation=f"Moyenne >= {codes.ParcoursBUT.BARRE_MOY}/20", + ) + # Compensation dans le RCUE ? + other_ue, other_formsemestre = but_validations.get_other_ue_rcue(ue, etud.id) + if other_ue is not None: + # inscrit à une autre UE du même RCUE + other_res: ResultatsSemestreBUT = res_sem.load_formsemestre_results( + other_formsemestre + ) + if (other_ue.id in other_res.etud_moy_ue) and ( + etud.id in other_res.etud_moy_ue[other_ue.id] + ): + other_moy_ue = other_res.etud_moy_ue[other_ue.id][etud.id] + # Moyenne RCUE: non pondérée (pour le moment) + moy_rcue = (moy_ue + other_moy_ue) / 2 + if moy_rcue > codes.NOTES_BARRE_GEN_COMPENSATION: # 10-epsilon + return DecisionsProposees( + code=codes.CMP, + explanation=f"Compensée par {other_ue} (moyenne RCUE={scu.fmt_note(moy_rcue)}/20", + ) + return DecisionsProposees( + code=[codes.AJ, codes.ADJ], + explanation="notes insuffisantes", + ) + + +def decisions_rcue_proposees( + etud: Identite, + formsemestre_1: FormSemestre, + ue_1: UniteEns, + formsemestre_2: FormSemestre, + ue_2: UniteEns, +) -> DecisionsProposees: + """Liste des codes de décisions que l'on peut proposer pour + le RCUE de cet étudiant dans ces semestres. + + ADM, CMP, ADJ, AJ, RAT, DEF, ABAN + + La validation des deux UE du niveau d’une compétence emporte la validation de + l’ensemble des UE du niveau inférieur de cette même compétence. + """ + # + + +class BUTCursusEtud: + """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). + """ + 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 donéne 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/comp/res_but.py b/app/comp/res_but.py index 330f01918..0ea2e2eaf 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -219,7 +219,7 @@ class ResultatsSemestreBUT(NotesTableCompat): def etud_ues_ids(self, etudid: int) -> list[int]: """Liste des id d'UE auxquelles l'étudiant est inscrit (sans bonus). - (surchargée en BUT pour prendre en compte les parcours) + (surchargée ici pour prendre en compte les parcours) """ s = self.ues_inscr_parcours_df.loc[etudid] return s.index[s.notna()] diff --git a/app/models/but_validations.py b/app/models/but_validations.py index 6b5bb6e90..7d58ace45 100644 --- a/app/models/but_validations.py +++ b/app/models/but_validations.py @@ -1,14 +1,15 @@ # -*- coding: UTF-8 -* -"""Décisions de jury validations) des RCUE et années du BUT +"""Décisions de jury (validations) des RCUE et années du BUT """ -from app import db -from app import log +from sqlalchemy.sql import text +from app import db from app.models import CODE_STR_LEN +from app.models.but_refcomp import ApcNiveau from app.models.ues import UniteEns -from app.models.formsemestre import FormSemestre, FormSemestreInscription +from app.models.formsemestre import FormSemestre class ApcValidationRCUE(db.Model): @@ -51,24 +52,68 @@ class ApcValidationRCUE(db.Model): def __repr__(self): return f"<{self.__class__.__name__} {self.id} {self.etud} {self.ue1}/{self.ue2}:{self.code!r}>" + def niveau(self) -> ApcNiveau: + """Le niveau de compétence associé à cet RCUE.""" + # Par convention, il est donné par la seconde UE + return self.ue2.niveau_competence -def get_other_ue_rcue(ue: UniteEns, etudid: int) -> UniteEns: - """L'autre UE du RCUE (niveau de compétence) pour cet étudiant, - None si pas trouvée. + +def get_other_ue_rcue(ue: UniteEns, etudid: int) -> tuple[UniteEns, FormSemestre]: + """L'autre UE du RCUE (niveau de compétence) pour cet étudiant. + + Cherche une UE du même niveau de compétence, à laquelle l'étudiant soit inscrit. + Résultat: le couple (UE, FormSemestre), ou (None, None) si pas trouvée. """ if (ue.niveau_competence is None) or (ue.semestre_idx is None): - return None - q = UniteEns.query.filter( - FormSemestreInscription.etudid == etudid, - FormSemestreInscription.formsemestre_id == FormSemestre.id, - FormSemestre.formation_id == UniteEns.formation_id, - FormSemestre.semestre_id == UniteEns.semestre_idx, - UniteEns.niveau_competence_id == ue.niveau_competence_id, - UniteEns.semestre_idx != ue.semestre_idx, + return None, None + + if ue.semestre_idx % 2: + other_semestre_idx = ue.semestre_idx + 1 + else: + other_semestre_idx = ue.semestre_idx - 1 + + cursor = db.session.execute( + text( + """SELECT + ue.id, sem.id + FROM + notes_ue ue, + notes_formsemestre_inscription inscr, + notes_formsemestre sem + + WHERE + inscr.etudid = :etudid + AND inscr.formsemestre_id = sem.id + + AND sem.semestre_id = :other_semestre_idx + AND ue.formation_id = sem.formation_id + AND ue.niveau_competence_id = :ue_niveau_competence_id + AND ue.semestre_idx = :other_semestre_idx + """ + ), + { + "etudid": etudid, + "other_semestre_idx": other_semestre_idx, + "ue_niveau_competence_id": ue.niveau_competence_id, + }, ) - if q.count() > 1: - log("Warning: get_other_ue_rcue: {q.count()} candidates UE") - return q.first() + r = cursor.fetchone() + if r is None: + return None, None + + return UniteEns.query.get(r[0]), FormSemestre.query.get(r[1]) + + # q = UniteEns.query.filter( + # FormSemestreInscription.etudid == etudid, + # FormSemestreInscription.formsemestre_id == FormSemestre.id, + # FormSemestre.formation_id == UniteEns.formation_id, + # FormSemestre.semestre_id == UniteEns.semestre_idx, + # UniteEns.niveau_competence_id == ue.niveau_competence_id, + # UniteEns.semestre_idx != ue.semestre_idx, + # ) + # if q.count() > 1: + # log("Warning: get_other_ue_rcue: {q.count()} candidates UE") + # return q.first() class ApcValidationAnnee(db.Model): diff --git a/app/models/formations.py b/app/models/formations.py index dc8ccb801..9f460cff8 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -8,6 +8,7 @@ from app.comp import df_cache from app.models import SHORT_STR_LEN from app.models.but_refcomp import ( ApcAnneeParcours, + ApcCompetence, ApcNiveau, ApcParcours, ApcParcoursNiveauCompetence, @@ -170,6 +171,27 @@ class Formation(db.Model): ApcAnneeParcours.parcours_id == parcour.id, ) + def query_competences_parcour( + self, parcour: ApcParcours + ) -> flask_sqlalchemy.BaseQuery: + """Les ApcCompetences d'un parcours de la formation. + None si pas de référentiel de compétences. + """ + if self.referentiel_competence_id is None: + return None + return ( + ApcCompetence.query.filter_by(referentiel_id=self.referentiel_competence_id) + .join( + ApcParcoursNiveauCompetence, + ApcParcoursNiveauCompetence.competence_id == ApcCompetence.id, + ) + .join( + ApcAnneeParcours, + ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id, + ) + .filter(ApcAnneeParcours.parcours_id == parcour.id) + ) + class Matiere(db.Model): """Matières: regroupe les modules d'une UE diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index d3375a638..7dc083825 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -221,7 +221,7 @@ class FormSemestre(db.Model): """UE que suit l'étudiant dans ce semestre BUT en fonction du parcours dans lequel il est inscrit. - Si voulez les UE d'un parcour, il est plus efficace de passer par + Si voulez les UE d'un parcours, il est plus efficace de passer par `formation.query_ues_parcour(parcour)`. """ return self.query_ues().filter( @@ -382,6 +382,11 @@ class FormSemestre(db.Model): "True si l'user est l'un des responsables du semestre" return user.id in [u.id for u in self.responsables] + def annee_scolaire(self) -> int: + """L'année de début de l'année scolaire. + Par exemple, 2022 si le semestre va de septebre 2022 à février 2023.""" + return scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month) + def annee_scolaire_str(self): "2021 - 2022" return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month) diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 2cf0be400..156f5560a 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -138,9 +138,9 @@ def sco_header( # optional args page_title="", # page title no_side_bar=False, # hide sidebar - cssstyles=[], # additionals CSS sheets - javascripts=[], # additionals JS filenames to load - scripts=[], # script to put in page header + cssstyles=(), # additionals CSS sheets + javascripts=(), # additionals JS filenames to load + scripts=(), # script to put in page header bodyOnLoad="", # JS init_qtip=False, # include qTip init_google_maps=False, # Google maps @@ -148,6 +148,8 @@ def sco_header( titrebandeau="", # titre dans bandeau superieur head_message="", # message action (petit cadre jaune en haut) user_check=True, # verifie passwords temporaires + etudid=None, + formsemestre_id=None, ): "Main HTML page header for ScoDoc" from app.scodoc.sco_formsemestre_status import formsemestre_page_title @@ -281,14 +283,14 @@ def sco_header( H.append(scu.CUSTOM_HTML_HEADER) # if not no_side_bar: - H.append(html_sidebar.sidebar()) + H.append(html_sidebar.sidebar(etudid)) H.append("""