diff --git a/app/comp/jury.py b/app/comp/jury.py index bb7cd0d4e..6581a2fb0 100644 --- a/app/comp/jury.py +++ b/app/comp/jury.py @@ -34,6 +34,13 @@ class ValidationsSemestre(ResultatsCache): """Décisions sur des UEs dans ce semestre: { etudid : { ue_id : { 'code' : Note|ADM|CMP, 'event_date' }}} """ + self.ue_capitalisees: pd.DataFrame = None + """DataFrame, index etudid + formsemestre_id : origine de l'UE capitalisée + is_external : vrai si validation effectuée dans un semestre extérieur + ue_id : dans le semestre origine (pas toujours de la même formation) + ue_code : code de l'UE, moy_ue : note enregistrée, + event_date : date de la validation (jury).""" if not self.load_cached(): self.compute() diff --git a/app/comp/res_but.py b/app/comp/res_but.py index b9be879b0..c5267596a 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -12,6 +12,7 @@ from app.comp import moy_ue, moy_sem, inscr_mod from app.comp.res_common import NotesTableCompat from app.comp.bonus_spo import BonusSport from app.models import ScoDocSiteConfig +from app.models.ues import UniteEns from app.scodoc.sco_codes_parcours import UE_SPORT @@ -93,11 +94,14 @@ class ResultatsSemestreBUT(NotesTableCompat): self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) # Moyenne générale indicative: - # (note: le bonus sport a déjà été appliqué aux moyenens d'UE, et impacte + # (note: le bonus sport a déjà été appliqué aux moyennes d'UE, et impacte # donc la moyenne indicative) self.etud_moy_gen = moy_sem.compute_sem_moys_apc( self.etud_moy_ue, self.modimpl_coefs_df ) + # --- UE capitalisées + self.apply_capitalisation() + # --- Classements: self.compute_rangs() @@ -110,3 +114,12 @@ class ResultatsSemestreBUT(NotesTableCompat): etud_idx = self.etud_index[etudid] # moyenne sur les UE: return self.sem_cube[etud_idx, mod_idx].mean() + + def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float: + """Détermine le coefficient de l'UE pour cet étudiant. + N'est utilisé que pour l'injection des UE capitalisées dans la + moyenne générale. + En BUT, c'est simple: Coef = somme des coefs des modules vers cette UE. + (ne dépend pas des modules auxquels est inscrit l'étudiant, ). + """ + return self.modimpl_coefs_df.loc[ue.id].sum() diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py index 9fdb1be29..0d8f32423 100644 --- a/app/comp/res_classic.py +++ b/app/comp/res_classic.py @@ -6,15 +6,24 @@ """Résultats semestres classiques (non APC) """ + import numpy as np import pandas as pd +from sqlalchemy.sql import text +from flask import g, url_for + +from app import db +from app import log from app.comp import moy_mod, moy_ue, inscr_mod from app.comp.res_common import NotesTableCompat from app.comp.bonus_spo import BonusSport from app.models import ScoDocSiteConfig +from app.models.etudiants import Identite from app.models.formsemestre import FormSemestre +from app.models.ues import UniteEns from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_utils import ModuleType @@ -104,6 +113,9 @@ class ResultatsSemestreClassic(NotesTableCompat): self.bonus = ( bonus_mg # compat nt, utilisé pour l'afficher sur les bulletins ) + # --- UE capitalisées + self.apply_capitalisation() + # --- Classements: self.compute_rangs() @@ -132,6 +144,42 @@ class ResultatsSemestreClassic(NotesTableCompat): ), } + def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float: + """Détermine le coefficient de l'UE pour cet étudiant. + N'est utilisé que pour l'injection des UE capitalisées dans la + moyenne générale. + Coef = somme des coefs des modules de l'UE auxquels il est inscrit + """ + c = comp_etud_sum_coef_modules_ue(self.formsemestre.id, etudid, ue["ue_id"]) + if c is not None: # inscrit à au moins un module de cette UE + return c + # arfff: aucun moyen de déterminer le coefficient de façon sûre + log( + "* oups: calcul coef UE impossible\nformsemestre_id='%s'\netudid='%s'\nue=%s" + % (self.formsemestre.id, etudid, ue) + ) + etud: Identite = Identite.query.get(etudid) + raise ScoValueError( + """

Coefficient de l'UE capitalisée %s impossible à déterminer + pour l'étudiant %s

+

Il faut saisir le coefficient de cette UE avant de continuer

+
+ """ + % ( + ue.acronyme, + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + etud.nom_disp(), + url_for( + "notes.formsemestre_edit_uecoefs", + scodoc_dept=g.scodoc_dept, + formsemestre_id=self.formsemestre.id, + err_ue_id=ue["ue_id"], + ), + ) + ) + + return 0.0 # ? + def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple[np.ndarray, dict]: """Calcule la matrice des notes du semestre @@ -165,3 +213,29 @@ def notes_sem_assemble_matrix(modimpls_notes: list[pd.Series]) -> np.ndarray: modimpls_notes = np.stack(modimpls_notes_arr) # passe de (mod x etud) à (etud x mod) return modimpls_notes.T + + +def comp_etud_sum_coef_modules_ue(formsemestre_id, etudid, ue_id): + """Somme des coefficients des modules de l'UE dans lesquels cet étudiant est inscrit + ou None s'il n'y a aucun module. + """ + # comme l'ancien notes_table.comp_etud_sum_coef_modules_ue + # mais en raw sqlalchemy et la somme en SQL + sql = text( + """ + SELECT sum(mod.coefficient) + FROM notes_modules mod, notes_moduleimpl mi, notes_moduleimpl_inscription ins + WHERE mod.id = mi.module_id + and ins.etudid = :etudid + and ins.moduleimpl_id = mi.id + and mi.formsemestre_id = :formsemestre_id + and mod.ue_id = :ue_id + """ + ) + cursor = db.session.execute( + sql, {"etudid": etudid, "formsemestre_id": formsemestre_id, "ue_id": ue_id} + ) + r = cursor.fetchone() + if r is None: + return None + return r[0] diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 69300d998..09d3ed75f 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -8,17 +8,22 @@ from collections import defaultdict, Counter from functools import cached_property import numpy as np import pandas as pd + +from flask import g + from app.comp.aux_stats import StatsMoyenne from app.comp import moy_sem from app.comp.res_cache import ResultatsCache from app.comp import res_sem from app.comp.moy_mod import ModuleImplResults from app.models import FormSemestre, Identite, ModuleImpl +from app.models import FormSemestreUECoef from app.models.ues import UniteEns from app.scodoc import sco_utils as scu from app.scodoc import sco_evaluations from app.scodoc.sco_cache import ResultatsSemestreCache from app.scodoc.sco_codes_parcours import UE_SPORT, ATT, DEF +from app.scodoc.sco_exceptions import ScoValueError # Il faut bien distinguer # - ce qui est caché de façon persistente (via redis): @@ -53,6 +58,7 @@ class ResultatsSemestre(ResultatsCache): "Résultats de chaque modimpl: dict { modimpl.id : ModuleImplResults(Classique ou BUT) }" self.etud_coef_ue_df = None """coefs d'UE effectifs pour chaque étudiant (pour form. classiques)""" + self.validations = None def compute(self): "Charge les notes et inscriptions et calcule toutes les moyennes" @@ -155,6 +161,115 @@ class ResultatsSemestre(ResultatsCache): """ return self.etud_moy_ue > (seuil - scu.NOTES_TOLERANCE) + def apply_capitalisation(self): + """Recalcule la moyenne générale pour prendre en compte d'éventuelles + UE capitalisées. + """ + # Supposant qu'il y a peu d'UE capitalisées, + # on va soustraire la moyenne d'UE et ajouter celle de l'UE capitalisée. + # return # XXX XXX XXX + if not self.validations: + self.validations = res_sem.load_formsemestre_validations(self.formsemestre) + ue_capitalisees = self.validations.ue_capitalisees + ue_by_code = {} + for etudid in ue_capitalisees.index: + recompute_mg = False + # ue_codes = set(ue_capitalisees.loc[etudid]["ue_code"]) + # for ue_code in ue_codes: + # ue = ue_by_code.get(ue_code) + # if ue is None: + # ue = self.formsemestre.query_ues.filter_by(ue_code=ue_code) + # ue_by_code[ue_code] = ue + + # Quand il y a une capitalisation, vérifie toutes les UEs + sum_notes_ue = 0.0 + sum_coefs_ue = 0.0 + for ue in self.formsemestre.query_ues(): + ue_cap = self.get_etud_ue_status(etudid, ue.id) + if ue_cap["is_capitalized"]: + recompute_mg = True + coef = ue_cap["coef_ue"] + if not np.isnan(ue_cap["moy"]): + sum_notes_ue += ue_cap["moy"] * coef + sum_coefs_ue += coef + + if recompute_mg and sum_coefs_ue > 0.0: + # On doit prendre en compte une ou plusieurs UE capitalisées + # et donc recalculer la moyenne générale + self.etud_moy_gen[etudid] = sum_notes_ue / sum_coefs_ue + + def _get_etud_ue_cap(self, etudid, ue): + """""" + capitalisations = self.validations.ue_capitalisees.loc[etudid] + if isinstance(capitalisations, pd.DataFrame): + ue_cap = capitalisations[capitalisations["ue_code"] == ue.ue_code] + if isinstance(ue_cap, pd.DataFrame) and not ue_cap.empty: + # si plusieurs fois capitalisée, prend le max + cap_idx = ue_cap["moy_ue"].values.argmax() + ue_cap = ue_cap.iloc[cap_idx] + else: + if capitalisations["ue_code"] == ue.ue_code: + ue_cap = capitalisations + else: + ue_cap = None + return ue_cap + + def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict: + """L'état de l'UE pour cet étudiant. + Result: dict + """ + if not self.validations: + self.validations = res_sem.load_formsemestre_validations(self.formsemestre) + ue = UniteEns.query.get(ue_id) # TODO cacher nos UEs ? + cur_moy_ue = self.etud_moy_ue[ue_id][etudid] + moy_ue = cur_moy_ue + is_capitalized = False + if etudid in self.validations.ue_capitalisees.index: + ue_cap = self._get_etud_ue_cap(etudid, ue) + if ue_cap is not None and not ue_cap.empty: + if ue_cap["moy_ue"] > cur_moy_ue: + moy_ue = ue_cap["moy_ue"] + is_capitalized = True + if is_capitalized: + coef_ue = 1.0 + coef_ue = self.etud_coef_ue_df[ue_id][etudid] + + return { + "is_capitalized": is_capitalized, + "is_external": ue_cap["is_external"] if is_capitalized else ue.is_external, + "coef_ue": coef_ue, + "cur_moy_ue": cur_moy_ue, + "moy": moy_ue, + "event_date": ue_cap["event_date"] if is_capitalized else None, + "ue": ue.to_dict(), + "formsemestre_id": ue_cap["formsemestre_id"] if is_capitalized else None, + "capitalized_ue_id": ue_cap["ue_id"] if is_capitalized else None, + } + + def get_etud_ue_cap_coef(self, etudid, ue, ue_cap): + """Calcule le coefficient d'une UE capitalisée, pour cet étudiant, + injectée dans le semestre courant. + + ue : ue du semestre courant + + ue_cap = resultat de formsemestre_get_etud_capitalisation + { 'ue_id' (dans le semestre source), + 'ue_code', 'moy', 'event_date','formsemestre_id' } + """ + # 1- Coefficient explicitement déclaré dans le semestre courant pour cette UE ? + ue_coef_db = FormSemestreUECoef.query.filter_by( + formsemestre_id=self.formsemestre.id, ue_id=ue.id + ).first() + if ue_coef_db is not None: + return ue_coef_db.coefficient + + # En APC: somme des coefs des modules vers cette UE + # En classique: Capitalisation UE externe: quel coef appliquer ? + # En ScoDoc 7, calculait la somme des coefs dans l'UE du semestre d'origine + # ici si l'étudiant est inscrit dans le semestre courant, + # somme des coefs des modules de l'UE auxquels il est inscrit + return self.compute_etud_ue_coef(etudid, ue) + # Pour raccorder le code des anciens codes qui attendent une NoteTable class NotesTableCompat(ResultatsSemestre): @@ -189,7 +304,6 @@ class NotesTableCompat(ResultatsSemestre): self.moy_moy = "NA" self.expr_diagnostics = "" self.parcours = self.formsemestre.formation.get_parcours() - self.validations = None def get_etudids(self, sorted=False) -> list[int]: """Liste des etudids inscrits, incluant les démissionnaires. @@ -353,16 +467,6 @@ class NotesTableCompat(ResultatsSemestre): "ects_fond": 0.0, # not implemented (anciennemment pour école ingé) } - def get_etud_ue_status(self, etudid: int, ue_id: int): - coef_ue = self.etud_coef_ue_df[ue_id][etudid] - return { - "is_capitalized": False, # XXX TODO - "is_external": False, # XXX TODO - "coef_ue": coef_ue, # XXX TODO - "cur_moy_ue": self.etud_moy_ue[ue_id][etudid], - "moy": self.etud_moy_ue[ue_id][etudid], - } - def get_etud_rang(self, etudid: int): return self.etud_moy_gen_ranks.get(etudid, 99999) # XXX diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index e4b8fc8c8..932e3fecb 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -427,10 +427,12 @@ class FormSemestreUECoef(db.Model): formsemestre_id = db.Column( db.Integer, db.ForeignKey("notes_formsemestre.id"), + index=True, ) ue_id = db.Column( db.Integer, db.ForeignKey("notes_ue.id"), + index=True, ) coefficient = db.Column(db.Float, nullable=False) diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py index b9b4630ce..e5077543b 100644 --- a/app/scodoc/notes_table.py +++ b/app/scodoc/notes_table.py @@ -703,11 +703,12 @@ class NotesTable: où ue_status = { 'est_inscrit' : True si étudiant inscrit à au moins un module de cette UE 'moy' : moyenne, avec capitalisation eventuelle + 'capitalized_ue_id' : id de l'UE capitalisée 'coef_ue' : coef de l'UE utilisé pour le calcul de la moyenne générale (la somme des coefs des modules, ou le coef d'UE capitalisée, ou encore le coef d'UE si l'option use_ue_coefs est active) 'cur_moy_ue' : moyenne de l'UE en cours (sans considérer de capitalisation) - 'cur_coef_ue': coefficient de l'UE courante + 'cur_coef_ue': coefficient de l'UE courante (inutilisé ?) 'is_capitalized' : True|False, 'ects_pot' : (float) nb de crédits ECTS qui seraient validés (sous réserve de validation par le jury), 'ects_pot_fond': 0. si UE non fondamentale, = ects_pot sinon, diff --git a/app/scodoc/sco_pvjury.py b/app/scodoc/sco_pvjury.py index ecf2d4887..4479409d0 100644 --- a/app/scodoc/sco_pvjury.py +++ b/app/scodoc/sco_pvjury.py @@ -165,10 +165,7 @@ def _comp_ects_capitalises_by_ue_code(nt, etudid): for ue in ues: ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) if ue_status["is_capitalized"]: - try: - ects_val = float(ue_status["ue"]["ects"]) - except (ValueError, TypeError): - ects_val = 0.0 + ects_val = float(ue_status["ue"]["ects"] or 0.0) ects_by_ue_code[ue["ue_code"]] = ects_val return ects_by_ue_code