MonScoDocEssai/app/comp/res_common.py

1069 lines
46 KiB
Python

##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Résultats semestre: méthodes communes aux formations classiques et APC
"""
from collections import Counter
from collections.abc import Generator
from functools import cached_property
import numpy as np
import pandas as pd
from flask import g, url_for
from app.auth.models import User
from app.comp import res_sem
from app.comp.res_cache import ResultatsCache
from app.comp.jury import ValidationsSemestre
from app.comp.moy_mod import ModuleImplResults
from app.models import FormSemestre, FormSemestreUECoef
from app.models import Identite
from app.models import ModuleImpl, ModuleImplInscription
from app.models.ues import UniteEns
from app.scodoc.sco_cache import ResultatsSemestreCache
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF, DEM
from app.scodoc import sco_evaluation_db
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_groups
from app.scodoc import sco_utils as scu
# Il faut bien distinguer
# - ce qui est caché de façon persistente (via redis):
# ce sont les attributs listés dans `_cached_attrs`
# le stockage et l'invalidation sont gérés dans sco_cache.py
#
# - les valeurs cachées durant le temps d'une requête
# (durée de vie de l'instance de ResultatsSemestre)
# qui sont notamment les attributs décorés par `@cached_property``
#
class ResultatsSemestre(ResultatsCache):
"""Les résultats (notes, ...) d'un formsemestre
Classe commune à toutes les formations (classiques, BUT)
"""
_cached_attrs = (
"bonus",
"bonus_ues",
"dispense_ues",
"etud_coef_ue_df",
"etud_moy_gen_ranks",
"etud_moy_gen",
"etud_moy_ue",
"modimpl_inscr_df",
"modimpls_results",
"moyennes_matieres",
)
def __init__(self, formsemestre: FormSemestre):
super().__init__(formsemestre, ResultatsSemestreCache)
# BUT ou standard ? (apc == "approche par compétences")
self.is_apc = formsemestre.formation.is_apc()
# Attributs "virtuels", définis dans les sous-classes
self.bonus: pd.Series = None # virtuel
"Bonus sur moy. gen. Series de float, index etudid"
self.bonus_ues: pd.DataFrame = None # virtuel
"DataFrame de float, index etudid, columns: ue.id"
self.dispense_ues: set[tuple[int, int]] = set()
"""set des dispenses d'UE: (etudid, ue_id), en APC seulement."""
# ResultatsSemestreBUT ou ResultatsSemestreClassic
self.etud_moy_ue = {}
"etud_moy_ue: DataFrame columns UE, rows etudid"
self.etud_moy_gen: pd.Series = None
self.etud_moy_gen_ranks = {}
self.etud_moy_gen_ranks_int = {}
self.moy_gen_rangs_by_group = None # virtual
self.modimpl_inscr_df: pd.DataFrame = None
"Inscriptions: row etudid, col modimlpl_id"
self.modimpls_results: ModuleImplResults = None
"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.modimpl_coefs_df: pd.DataFrame = None
"""Coefs APC: rows = UEs (sans bonus), columns = modimpl, value = coef."""
self.validations = None
self.moyennes_matieres = {}
"""Moyennes de matières, si calculées. { matiere_id : Series, index etudid }"""
def __repr__(self):
return f"<{self.__class__.__name__}(formsemestre='{self.formsemestre}')>"
def compute(self):
"Charge les notes et inscriptions et calcule toutes les moyennes"
# voir ce qui est chargé / calculé ici et dans les sous-classes
raise NotImplementedError()
def get_inscriptions_counts(self) -> Counter:
"""Nombre d'inscrits, défaillants, démissionnaires.
Exemple: res.get_inscriptions_counts()[scu.INSCRIT]
Result: a collections.Counter instance
"""
return Counter(ins.etat for ins in self.formsemestre.inscriptions)
@cached_property
def etuds(self) -> list[Identite]:
"Liste des inscrits au semestre, avec les démissionnaires et les défaillants"
# nb: si la liste des inscrits change, ResultatsSemestre devient invalide
return self.formsemestre.get_inscrits(include_demdef=True)
@cached_property
def etud_index(self) -> dict[int, int]:
"dict { etudid : indice dans les inscrits }"
return {e.id: idx for idx, e in enumerate(self.etuds)}
def etud_ues_ids(self, etudid: int) -> list[int]:
"""Liste des UE auxquelles l'etudiant est inscrit, sans bonus
(surchargée en BUT pour prendre en compte les parcours)
"""
# Pour les formations classiques, etudid n'est pas utilisé
# car tous les étudiants sont inscrits à toutes les UE
return [ue.id for ue in self.ues if ue.type != UE_SPORT]
def etud_ues(self, etudid: int) -> Generator[UniteEns]:
"""Liste des UE auxquelles l'étudiant est inscrit."""
return (UniteEns.query.get(ue_id) for ue_id in self.etud_ues_ids(etudid))
def etud_ects_tot_sem(self, etudid: int) -> float:
"""Le total des ECTS associées à ce semestre (que l'étudiant peut ou non valider)"""
etud_ues = self.etud_ues(etudid)
return sum([ue.ects or 0 for ue in etud_ues]) if etud_ues else 0.0
def modimpl_notes(self, modimpl_id: int, ue_id: int) -> np.ndarray:
"""Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue.
Utile pour stats bottom tableau recap.
Résultat: 1d array of float
"""
# différent en BUT et classique: virtuelle
raise NotImplementedError
@cached_property
def etuds_dict(self) -> dict[int, Identite]:
"""dict { etudid : Identite } inscrits au semestre,
avec les démissionnaires et defs."""
return {etud.id: etud for etud in self.etuds}
@cached_property
def ues(self) -> list[UniteEns]:
"""Liste des UEs du semestre (avec les UE bonus sport)
(indices des DataFrames).
Note: un étudiant n'est pas nécessairement inscrit dans toutes ces UEs.
"""
return self.formsemestre.query_ues(with_sport=True).all()
@cached_property
def ressources(self):
"Liste des ressources du semestre, triées par numéro de module"
return [
m
for m in self.formsemestre.modimpls_sorted
if m.module.module_type == scu.ModuleType.RESSOURCE
]
@cached_property
def saes(self):
"Liste des SAÉs du semestre, triées par numéro de module"
return [
m
for m in self.formsemestre.modimpls_sorted
if m.module.module_type == scu.ModuleType.SAE
]
# --- JURY...
def load_validations(self) -> ValidationsSemestre:
"""Load validations, set attribute and return value"""
if not self.validations:
self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
return self.validations
def get_etud_ue_validables(self, etudid: int) -> list[UniteEns]:
"""Liste des UEs du semestre qui doivent être validées
Rappel: l'étudiant est inscrit à des modimpls et non à des UEs.
- En BUT: on considère que l'étudiant va (ou non) valider toutes les UEs des modules
du parcours.
- En classique: toutes les UEs des modimpls auxquels l'étudiant est inscrit sont
susceptibles d'être validées.
Les UE "bonus" (sport) ne sont jamais "validables".
"""
if self.is_apc:
return list(self.etud_ues(etudid))
# Formations classiques:
# restreint aux UE auxquelles l'étudiant est inscrit (dans l'un des modimpls)
ues = {
modimpl.module.ue
for modimpl in self.formsemestre.modimpls_sorted
if self.modimpl_inscr_df[modimpl.id][etudid]
}
ues = sorted(list(ues), key=lambda x: x.numero or 0)
return ues
def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]:
"""Liste des modimpl de cette UE auxquels l'étudiant est inscrit.
Utile en formations classiques, surchargée pour le BUT.
Inclus modules bonus le cas échéant.
"""
# Utilisée pour l'affichage ou non de l'UE sur le bulletin
# Méthode surchargée en BUT
modimpls = [
modimpl
for modimpl in self.formsemestre.modimpls_sorted
if modimpl.module.ue.id == ue.id
and self.modimpl_inscr_df[modimpl.id][etudid]
]
if not with_bonus:
return [
modimpl for modimpl in modimpls if modimpl.module.ue.type != UE_SPORT
]
return modimpls
@cached_property
def ue_au_dessus(self, seuil=10.0) -> pd.DataFrame:
"""DataFrame columns UE, rows etudid, valeurs: bool
Par exemple, pour avoir le nombre d'UE au dessus de 10 pour l'étudiant etudid
nb_ues_ok = sum(res.ue_au_dessus().loc[etudid])
"""
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 recalcule les moyennes gen des etuds ayant des UE capitalisée.
self.load_validations()
ue_capitalisees = self.validations.ue_capitalisees
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 None:
continue
if ue_cap["is_capitalized"]:
recompute_mg = True
coef = ue_cap["coef_ue"]
if not np.isnan(ue_cap["moy"]) and coef:
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
# Ajoute le bonus sport
if self.bonus is not None and self.bonus[etudid]:
self.etud_moy_gen[etudid] += self.bonus[etudid]
self.etud_moy_gen[etudid] = max(
0.0, min(self.etud_moy_gen[etudid], 20.0)
)
def get_etud_etat(self, etudid: int) -> str:
"Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)"
ins = self.formsemestre.etuds_inscriptions.get(etudid, None)
if ins is None:
return ""
return ins.etat
def _get_etud_ue_cap(self, etudid: int, ue: UniteEns) -> dict:
"""Donne les informations sur la capitalisation de l'UE ue pour cet étudiant.
Résultat:
Si pas capitalisée: None
Si capitalisée: un dict, avec les colonnes de validation.
"""
capitalisations = self.validations.ue_capitalisees.loc[etudid]
if isinstance(capitalisations, pd.DataFrame):
ue_cap = capitalisations[capitalisations["ue_code"] == ue.ue_code]
if ue_cap.empty:
return None
if isinstance(ue_cap, pd.DataFrame):
# 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:
return None
# converti la Series en dict, afin que les np.int64 reviennent en int
# et remplace les NaN (venant des NULL en base) par des None
ue_cap_dict = ue_cap.to_dict()
if ue_cap_dict["formsemestre_id"] is not None and np.isnan(
ue_cap_dict["formsemestre_id"]
):
ue_cap_dict["formsemestre_id"] = None
if ue_cap_dict["compense_formsemestre_id"] is not None and np.isnan(
ue_cap_dict["compense_formsemestre_id"]
):
ue_cap_dict["compense_formsemestre_id"] = None
return ue_cap_dict
def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict:
"""L'état de l'UE pour cet étudiant.
Result: dict, ou None si l'UE n'est pas dans ce semestre.
"""
ue = UniteEns.query.get(ue_id)
if ue.type == UE_SPORT:
return {
"is_capitalized": False,
"was_capitalized": False,
"is_external": False,
"coef_ue": 0.0,
"cur_moy_ue": 0.0,
"moy": 0.0,
"event_date": None,
"ue": ue.to_dict(),
"formsemestre_id": None,
"capitalized_ue_id": None,
"ects_pot": 0.0,
"ects": 0.0,
"ects_ue": ue.ects,
}
if not ue_id in self.etud_moy_ue:
return None
if not self.validations:
self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
cur_moy_ue = self.etud_moy_ue[ue_id][etudid]
moy_ue = cur_moy_ue
is_capitalized = False # si l'UE prise en compte est une UE capitalisée
# s'il y a precedemment une UE capitalisée (pas forcement meilleure):
was_capitalized = False
if etudid in self.validations.ue_capitalisees.index:
ue_cap = self._get_etud_ue_cap(etudid, ue)
if ue_cap and not np.isnan(ue_cap["moy_ue"]):
was_capitalized = True
if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue):
moy_ue = ue_cap["moy_ue"]
is_capitalized = True
# Coef l'UE dans le semestre courant:
if self.is_apc:
# utilise les ECTS comme coef.
coef_ue = ue.ects
else:
# formations classiques
coef_ue = self.etud_coef_ue_df[ue_id][etudid]
if (not coef_ue) and is_capitalized: # étudiant non inscrit dans l'UE courante
if self.is_apc:
# Coefs de l'UE capitalisée en formation APC: donné par ses ECTS
ue_capitalized = UniteEns.query.get(ue_cap["ue_id"])
coef_ue = ue_capitalized.ects
if coef_ue is None:
orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"])
raise ScoValueError(
f"""L'UE capitalisée {ue_capitalized.acronyme}
du semestre {orig_sem.titre_annee()}
n'a pas d'indication d'ECTS.
Corrigez ou faite corriger le programme
<a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept,
formation_id=ue_capitalized.formation_id)}">via cette page</a>.
"""
)
else:
# Coefs de l'UE capitalisée en formation classique:
# va chercher le coef dans le semestre d'origine
coef_ue = ModuleImplInscription.sum_coefs_modimpl_ue(
ue_cap["formsemestre_id"], etudid, ue_cap["ue_id"]
)
return {
"is_capitalized": is_capitalized,
"was_capitalized": was_capitalized,
"is_external": ue_cap["is_external"] if is_capitalized else ue.is_external,
"coef_ue": coef_ue,
"ects_pot": ue.ects or 0.0,
"ects": self.validations.decisions_jury_ues.get(etudid, {})
.get(ue.id, {})
.get("ects", 0.0)
if self.validations.decisions_jury_ues
else 0.0,
"ects_ue": ue.ects,
"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 compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float:
"Détermine le coefficient de l'UE pour cet étudiant."
# calcul différent en classique et BUT
raise NotImplementedError()
def get_etud_ue_cap_coef(self, etudid, ue, ue_cap): # UNUSED in ScoDoc 9
"""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)
# --- TABLEAU RECAP
def get_table_recap(
self,
convert_values=False,
include_evaluations=False,
mode_jury=False,
allow_html=True,
):
"""Table récap. des résultats.
allow_html: si vrai, peut mettre du HTML dans les valeurs
Result: tuple avec
- rows: liste de dicts { column_id : value }
- titles: { column_id : title }
- columns_ids: (liste des id de colonnes)
Si convert_values, transforme les notes en chaines ("12.34").
Les colonnes générées sont:
etudid
rang : rang indicatif (basé sur moy gen)
moy_gen : moy gen indicative
moy_ue_<ue_id>, ..., les moyennes d'UE
moy_res_<modimpl_id>_<ue_id>, ... les moyennes de ressources dans l'UE
moy_sae_<modimpl_id>_<ue_id>, ... les moyennes de SAE dans l'UE
On ajoute aussi des attributs:
- pour les lignes:
_css_row_class (inutilisé pour le monent)
_<column_id>_class classe css:
- la moyenne générale a la classe col_moy_gen
- les colonnes SAE ont la classe col_sae
- les colonnes Resources ont la classe col_res
- les colonnes d'UE ont la classe col_ue
- les colonnes de modules (SAE ou res.) d'une UE ont la classe mod_ue_<ue_id>
_<column_id>_order : clé de tri
"""
if convert_values:
fmt_note = scu.fmt_note
else:
fmt_note = lambda x: x
parcours = self.formsemestre.formation.get_parcours()
barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE
barre_valid_ue = parcours.NOTES_BARRE_VALID_UE
barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING
NO_NOTE = "-" # contenu des cellules sans notes
rows = []
# column_id : title
titles = {}
# les titres en footer: les mêmes, mais avec des bulles et liens:
titles_bot = {}
dict_nom_res = {} # cache uid : nomcomplet
def add_cell(
row: dict,
col_id: str,
title: str,
content: str,
classes: str = "",
idx: int = 100,
):
"Add a cell to our table. classes is a list of css class names"
row[col_id] = content
if classes:
row[f"_{col_id}_class"] = classes + f" c{idx}"
if not col_id in titles:
titles[col_id] = title
titles[f"_{col_id}_col_order"] = idx
if classes:
titles[f"_{col_id}_class"] = classes
return idx + 1
etuds_inscriptions = self.formsemestre.etuds_inscriptions
ues = self.formsemestre.query_ues(with_sport=True) # avec bonus
ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT]
modimpl_ids = set() # modimpl effectivement présents dans la table
for etudid in etuds_inscriptions:
idx = 0 # index de la colonne
etud = Identite.query.get(etudid)
row = {"etudid": etudid}
# --- Codes (seront cachés, mais exportés en excel)
idx = add_cell(row, "etudid", "etudid", etudid, "codes", idx)
idx = add_cell(
row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx
)
# --- Rang
if not self.formsemestre.block_moyenne_generale:
idx = add_cell(
row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx
)
row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}"
# --- Identité étudiant
idx = add_cell(
row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail", idx
)
idx = add_cell(
row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail", idx
)
row["_nom_disp_order"] = etud.sort_key
idx = add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail", idx)
idx = add_cell(
row, "nom_short", "Nom", etud.nom_short, "identite_court", idx
)
row["_nom_short_order"] = etud.sort_key
row["_nom_short_target"] = url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
formsemestre_id=self.formsemestre.id,
etudid=etudid,
)
row["_nom_short_data"] = {
"etudid": etud.id,
"nomprenom": etud.nomprenom,
}
row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"'
row["_nom_disp_target"] = row["_nom_short_target"]
row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"]
idx = 30 # début des colonnes de notes
# --- Moyenne générale
if not self.formsemestre.block_moyenne_generale:
moy_gen = self.etud_moy_gen.get(etudid, False)
note_class = ""
if moy_gen is False:
moy_gen = NO_NOTE
elif isinstance(moy_gen, float) and moy_gen < barre_moy:
note_class = " moy_ue_warning" # en rouge
idx = add_cell(
row,
"moy_gen",
"Moy",
fmt_note(moy_gen),
"col_moy_gen" + note_class,
idx,
)
titles_bot["_moy_gen_target_attrs"] = (
'title="moyenne indicative"' if self.is_apc else ""
)
# --- Moyenne d'UE
nb_ues_validables, nb_ues_warning = 0, 0
idx_ue_start = idx
for idx_ue, ue in enumerate(ues_sans_bonus):
ue_status = self.get_etud_ue_status(etudid, ue.id)
if ue_status is not None:
col_id = f"moy_ue_{ue.id}"
val = ue_status["moy"]
note_class = ""
if isinstance(val, float):
if val < barre_moy:
note_class = " moy_inf"
elif val >= barre_valid_ue:
note_class = " moy_ue_valid"
nb_ues_validables += 1
if val < barre_warning_ue:
note_class = " moy_ue_warning" # notes très basses
nb_ues_warning += 1
idx = add_cell(
row,
col_id,
ue.acronyme,
fmt_note(val),
"col_ue" + note_class,
idx_ue * 10000 + idx_ue_start,
)
titles_bot[
f"_{col_id}_target_attrs"
] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """
if mode_jury:
# pas d'autre colonnes de résultats
continue
# Bonus (sport) dans cette UE ?
# Le bonus sport appliqué sur cette UE
if (self.bonus_ues is not None) and (ue.id in self.bonus_ues):
val = self.bonus_ues[ue.id][etud.id] or ""
val_fmt = val_fmt_html = fmt_note(val)
if val:
val_fmt_html = f'<span class="green-arrow-up"></span><span class="sp2l">{val_fmt}</span>'
idx = add_cell(
row,
f"bonus_ue_{ue.id}",
f"Bonus {ue.acronyme}",
val_fmt_html if allow_html else val_fmt,
"col_ue_bonus",
idx_ue * 10000 + idx_ue_start + 1,
)
row[f"_bonus_ue_{ue.id}_xls"] = val_fmt
# Les moyennes des modules (ou ressources et SAÉs) dans cette UE
idx_malus = idx # place pour colonne malus à gauche des modules
idx += 1
for modimpl in self.modimpls_in_ue(ue, etudid, with_bonus=False):
if ue_status["is_capitalized"]:
val = "-c-"
else:
modimpl_results = self.modimpls_results.get(modimpl.id)
if modimpl_results: # pas bonus
if self.is_apc: # BUT
moys_vers_ue = modimpl_results.etuds_moy_module.get(
ue.id
)
val = (
moys_vers_ue.get(etudid, "?")
if moys_vers_ue is not None
else ""
)
else: # classique: Series indépendante de l'UE
val = modimpl_results.etuds_moy_module.get(
etudid, "?"
)
else:
val = ""
col_id = (
f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
)
val_fmt = val_fmt_html = fmt_note(val)
if convert_values and (
modimpl.module.module_type == scu.ModuleType.MALUS
):
val_fmt_html = (
(scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else ""
)
idx = add_cell(
row,
col_id,
modimpl.module.code,
val_fmt_html,
# class col_res mod_ue_123
f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}",
idx_ue * 10000
+ idx_ue_start
+ 1
+ (modimpl.module.module_type or 0) * 1000
+ (modimpl.module.numero or 0),
)
row[f"_{col_id}_xls"] = val_fmt
if modimpl.module.module_type == scu.ModuleType.MALUS:
titles[f"_{col_id}_col_order"] = idx_malus
titles_bot[f"_{col_id}_target"] = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
nom_resp = dict_nom_res.get(modimpl.responsable_id)
if nom_resp is None:
user = User.query.get(modimpl.responsable_id)
nom_resp = user.get_nomcomplet() if user else ""
dict_nom_res[modimpl.responsable_id] = nom_resp
titles_bot[
f"_{col_id}_target_attrs"
] = f""" title="{modimpl.module.titre} ({nom_resp})" """
modimpl_ids.add(modimpl.id)
nb_ues_etud_parcours = len(self.etud_ues_ids(etudid))
ue_valid_txt = (
ue_valid_txt_html
) = f"{nb_ues_validables}/{nb_ues_etud_parcours}"
if nb_ues_warning:
ue_valid_txt_html += " " + scu.EMO_WARNING
add_cell(
row,
"ues_validables",
"UEs",
ue_valid_txt_html,
"col_ue col_ues_validables",
29, # juste avant moy. gen.
)
row["_ues_validables_xls"] = ue_valid_txt
if nb_ues_warning:
row["_ues_validables_class"] += " moy_ue_warning"
elif nb_ues_validables < len(ues_sans_bonus):
row["_ues_validables_class"] += " moy_inf"
row["_ues_validables_order"] = nb_ues_validables # pour tri
if mode_jury and self.validations:
if self.is_apc:
# formations BUT: pas de code semestre, concatene ceux des UE
dec_ues = self.validations.decisions_jury_ues.get(etudid)
if dec_ues:
jury_code_sem = ",".join(
[dec_ues[ue_id].get("code", "") for ue_id in dec_ues]
)
else:
jury_code_sem = ""
else:
# formations classiques: code semestre
dec_sem = self.validations.decisions_jury.get(etudid)
jury_code_sem = dec_sem["code"] if dec_sem else ""
idx = add_cell(
row,
"jury_code_sem",
"Jury",
jury_code_sem,
"jury_code_sem",
1000,
)
idx = add_cell(
row,
"jury_link",
"",
f"""<a href="{url_for('notes.formsemestre_validation_etud_form',
scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre.id, etudid=etudid
)
}">{("saisir" if not jury_code_sem else "modifier")
if self.formsemestre.etat else "voir"} décisions</a>""",
"col_jury_link",
idx,
)
rows.append(row)
col_idx = self.recap_add_partitions(rows, titles)
self.recap_add_cursus(rows, titles, col_idx=col_idx + 1)
self._recap_add_admissions(rows, titles)
# tri par rang croissant
if not self.formsemestre.block_moyenne_generale:
rows.sort(key=lambda e: e["_rang_order"])
else:
rows.sort(key=lambda e: e["_ues_validables_order"], reverse=True)
# INFOS POUR FOOTER
bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note)
if include_evaluations:
self._recap_add_evaluations(rows, titles, bottom_infos)
# Ajoute style "col_empty" aux colonnes de modules vides
for col_id in titles:
c_class = f"_{col_id}_class"
if "col_empty" in bottom_infos["moy"].get(c_class, ""):
for row in rows:
row[c_class] = row.get(c_class, "") + " col_empty"
titles[c_class] += " col_empty"
for row in bottom_infos.values():
row[c_class] = row.get(c_class, "") + " col_empty"
# Ligne avec la classe de chaque colonne
# récupère le type à partir des classes css (hack...)
row_class = {}
for col_id in titles:
klass = titles.get(f"_{col_id}_class")
if klass:
row_class[col_id] = " ".join(
cls[4:] for cls in klass.split() if cls.startswith("col_")
)
# cette case (nb d'UE validables) a deux classes col_xxx, on en garde une seule:
if "ues_validables" in row_class[col_id]:
row_class[col_id] = "ues_validables"
bottom_infos["type_col"] = row_class
# --- TABLE FOOTER: ECTS, moyennes, min, max...
footer_rows = []
for (bottom_line, row) in bottom_infos.items():
# Cases vides à styler:
row["moy_gen"] = row.get("moy_gen", "")
row["_moy_gen_class"] = "col_moy_gen"
# titre de la ligne:
row["prenom"] = row["nom_short"] = (
row.get("_title", "") or bottom_line.capitalize()
)
row["_tr_class"] = bottom_line.lower() + (
(" " + row["_tr_class"]) if "_tr_class" in row else ""
)
footer_rows.append(row)
titles_bot.update(titles)
footer_rows.append(titles_bot)
column_ids = [title for title in titles if not title.startswith("_")]
column_ids.sort(
key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000)
)
return (rows, footer_rows, titles, column_ids)
def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict:
"""Les informations à mettre en bas de la table: min, max, moy, ECTS, Apo"""
row_min, row_max, row_moy, row_coef, row_ects, row_apo = (
{"_tr_class": "bottom_info", "_title": "Min."},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info", "_title": "Code Apogée"},
)
# --- ECTS
for ue in ues:
colid = f"moy_ue_{ue.id}"
row_ects[colid] = ue.ects
row_ects[f"_{colid}_class"] = "col_ue"
# style cases vides pour borders verticales
row_coef[colid] = ""
row_coef[f"_{colid}_class"] = "col_ue"
# row_apo[colid] = ue.code_apogee or ""
row_ects["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT])
row_ects["_moy_gen_class"] = "col_moy_gen"
# --- MIN, MAX, MOY, APO
row_min["moy_gen"] = fmt_note(self.etud_moy_gen.min())
row_max["moy_gen"] = fmt_note(self.etud_moy_gen.max())
row_moy["moy_gen"] = fmt_note(self.etud_moy_gen.mean())
for ue in ues:
colid = f"moy_ue_{ue.id}"
row_min[colid] = fmt_note(self.etud_moy_ue[ue.id].min())
row_max[colid] = fmt_note(self.etud_moy_ue[ue.id].max())
row_moy[colid] = fmt_note(self.etud_moy_ue[ue.id].mean())
row_min[f"_{colid}_class"] = "col_ue"
row_max[f"_{colid}_class"] = "col_ue"
row_moy[f"_{colid}_class"] = "col_ue"
row_apo[colid] = ue.code_apogee or ""
for modimpl in self.formsemestre.modimpls_sorted:
if modimpl.id in modimpl_ids:
colid = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
if self.is_apc:
coef = self.modimpl_coefs_df[modimpl.id][ue.id]
else:
coef = modimpl.module.coefficient or 0
row_coef[colid] = fmt_note(coef)
notes = self.modimpl_notes(modimpl.id, ue.id)
if np.isnan(notes).all():
# aucune note valide
row_min[colid] = np.nan
row_max[colid] = np.nan
moy = np.nan
else:
row_min[colid] = fmt_note(np.nanmin(notes))
row_max[colid] = fmt_note(np.nanmax(notes))
moy = np.nanmean(notes)
row_moy[colid] = fmt_note(moy)
if np.isnan(moy):
# aucune note dans ce module
row_moy[f"_{colid}_class"] = "col_empty"
row_apo[colid] = modimpl.module.code_apogee or ""
return { # { key : row } avec key = min, max, moy, coef, ...
"min": row_min,
"max": row_max,
"moy": row_moy,
"coef": row_coef,
"ects": row_ects,
"apo": row_apo,
}
def _recap_etud_groups_infos(
self, etudid: int, row: dict, titles: dict
): # XXX non utilisé
"""Table recap: ajoute à row les colonnes sur les groupes pour cet etud"""
# dec = self.get_etud_decision_sem(etudid)
# if dec:
# codes_nb[dec["code"]] += 1
row_class = ""
etud_etat = self.get_etud_etat(etudid)
if etud_etat == DEM:
gr_name = "Dém."
row_class = "dem"
elif etud_etat == DEF:
gr_name = "Déf."
row_class = "def"
else:
# XXX probablement à revoir pour utiliser données cachées,
# via get_etud_groups_in_partition ou autre
group = sco_groups.get_etud_main_group(etudid, self.formsemestre.id)
gr_name = group["group_name"] or ""
row["group"] = gr_name
row["_group_class"] = "group"
if row_class:
row["_tr_class"] = " ".join([row.get("_tr_class", ""), row_class])
titles["group"] = "Gr"
def _recap_add_admissions(self, rows: list[dict], titles: dict):
"""Ajoute les colonnes "admission"
rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "admission"
"""
fields = {
"bac": "Bac",
"specialite": "Spécialité",
"type_admission": "Type Adm.",
"classement": "Rg. Adm.",
}
first = True
for i, cid in enumerate(fields):
titles[f"_{cid}_col_order"] = 100000 + i # tout à droite
if first:
titles[f"_{cid}_class"] = "admission admission_first"
first = False
else:
titles[f"_{cid}_class"] = "admission"
titles.update(fields)
for row in rows:
etud = Identite.query.get(row["etudid"])
admission = etud.admission.first()
first = True
for cid in fields:
row[cid] = getattr(admission, cid) or ""
if first:
row[f"_{cid}_class"] = "admission admission_first"
first = False
else:
row[f"_{cid}_class"] = "admission"
def recap_add_cursus(self, rows: list[dict], titles: dict, col_idx: int = None):
"""Ajoute colonne avec code cursus, eg 'S1 S2 S1'"""
cid = "code_cursus"
titles[cid] = "Cursus"
titles[f"_{cid}_col_order"] = col_idx
formation_code = self.formsemestre.formation.formation_code
for row in rows:
etud = Identite.query.get(row["etudid"])
row[cid] = " ".join(
[
f"S{ins.formsemestre.semestre_id}"
for ins in reversed(etud.inscriptions())
if ins.formsemestre.formation.formation_code == formation_code
]
)
def recap_add_partitions(
self, rows: list[dict], titles: dict, col_idx: int = None
) -> int:
"""Ajoute les colonnes indiquant les groupes
rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "partition"
Renvoie l'indice de la dernière colonne utilisée
"""
partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups(
self.formsemestre.id
)
first_partition = True
col_order = 10 if col_idx is None else col_idx
for partition in partitions:
cid = f"part_{partition['partition_id']}"
rg_cid = cid + "_rg" # rang dans la partition
titles[cid] = partition["partition_name"]
if first_partition:
klass = "partition"
else:
klass = "partition partition_aux"
titles[f"_{cid}_class"] = klass
titles[f"_{cid}_col_order"] = col_order
titles[f"_{rg_cid}_col_order"] = col_order + 1
col_order += 2
if partition["bul_show_rank"]:
titles[rg_cid] = f"Rg {partition['partition_name']}"
titles[f"_{rg_cid}_class"] = "partition_rangs"
partition_etud_groups = partitions_etud_groups[partition["partition_id"]]
for row in rows:
group = None # group (dict) de l'étudiant dans cette partition
# dans NotesTableCompat, à revoir
etud_etat = self.get_etud_etat(row["etudid"])
if etud_etat == scu.DEMISSION:
gr_name = "Dém."
row["_tr_class"] = "dem"
elif etud_etat == DEF:
gr_name = "Déf."
row["_tr_class"] = "def"
else:
group = partition_etud_groups.get(row["etudid"])
gr_name = group["group_name"] if group else ""
if gr_name:
row[cid] = gr_name
row[f"_{cid}_class"] = klass
# Rangs dans groupe
if (
partition["bul_show_rank"]
and (group is not None)
and (group["id"] in self.moy_gen_rangs_by_group)
):
rang = self.moy_gen_rangs_by_group[group["id"]][0]
row[rg_cid] = rang.get(row["etudid"], "")
first_partition = False
return col_order
def _recap_add_evaluations(
self, rows: list[dict], titles: dict, bottom_infos: dict
):
"""Ajoute les colonnes avec les notes aux évaluations
rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "evaluation"
"""
# nouvelle ligne pour description évaluations:
bottom_infos["descr_evaluation"] = {
"_tr_class": "bottom_info",
"_title": "Description évaluation",
}
first_eval = True
index_col = 9000 # à droite
for modimpl in self.formsemestre.modimpls_sorted:
evals = self.modimpls_results[modimpl.id].get_evaluations_completes(modimpl)
eval_index = len(evals) - 1
inscrits = {i.etudid for i in modimpl.inscriptions}
first_eval_of_mod = True
for e in evals:
cid = f"eval_{e.id}"
titles[
cid
] = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}'
klass = "evaluation"
if first_eval:
klass += " first"
elif first_eval_of_mod:
klass += " first_of_mod"
titles[f"_{cid}_class"] = klass
first_eval_of_mod = first_eval = False
titles[f"_{cid}_col_order"] = index_col
index_col += 1
eval_index -= 1
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
e.evaluation_id
)
for row in rows:
etudid = row["etudid"]
if etudid in inscrits:
if etudid in notes_db:
val = notes_db[etudid]["value"]
else:
# Note manquante mais prise en compte immédiate: affiche ATT
val = scu.NOTES_ATTENTE
row[cid] = scu.fmt_note(val)
row[f"_{cid}_class"] = klass + {
"ABS": " abs",
"ATT": " att",
"EXC": " exc",
}.get(row[cid], "")
else:
row[cid] = "ni"
row[f"_{cid}_class"] = klass + " non_inscrit"
bottom_infos["coef"][cid] = e.coefficient
bottom_infos["min"][cid] = "0"
bottom_infos["max"][cid] = scu.fmt_note(e.note_max)
bottom_infos["descr_evaluation"][cid] = e.description or ""
bottom_infos["descr_evaluation"][f"_{cid}_target"] = url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e.id,
)