ScoDoc-Lille/app/but/bulletin_but.py

551 lines
22 KiB
Python

##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Génération bulletin BUT
"""
import collections
import datetime
import numpy as np
from flask import g, has_request_context, url_for
from app import db
from app.comp.res_but import ResultatsSemestreBUT
from app.models import Evaluation, FormSemestre, Identite
from app.models.groups import GroupDescr
from app.models.ues import UniteEns
from app.scodoc import sco_bulletins, sco_utils as scu
from app.scodoc import sco_bulletins_json
from app.scodoc import sco_bulletins_pdf
from app.scodoc import codes_cursus
from app.scodoc import sco_groups
from app.scodoc import sco_preferences
from app.scodoc.codes_cursus import UE_SPORT, DEF
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import fmt_note
class BulletinBUT:
"""Génération du bulletin BUT.
Cette classe génère des dictionnaires avec toutes les informations
du bulletin, qui sont immédiatement traduisibles en JSON.
"""
def __init__(self, formsemestre: FormSemestre):
""" """
self.res = ResultatsSemestreBUT(formsemestre)
self.prefs = sco_preferences.SemPreferences(formsemestre.id)
def etud_ue_mod_results(self, etud, ue, modimpls) -> dict:
"dict synthèse résultats dans l'UE pour les modules indiqués"
res = self.res
d = {}
etud_idx = res.etud_index[etud.id]
if ue.type != UE_SPORT:
ue_idx = res.modimpl_coefs_df.index.get_loc(ue.id)
etud_moy_module = res.sem_cube[etud_idx] # module x UE
for modimpl in modimpls:
if res.modimpl_inscr_df[modimpl.id][etud.id]: # si inscrit
if ue.type != UE_SPORT:
coef = res.modimpl_coefs_df[modimpl.id][ue.id]
if coef > 0:
d[modimpl.module.code] = {
"id": modimpl.id,
"coef": coef,
"moyenne": fmt_note(
etud_moy_module[
res.modimpl_coefs_df.columns.get_loc(modimpl.id)
][ue_idx]
),
}
# else: # modules dans UE bonus sport
# d[modimpl.module.code] = {
# "id": modimpl.id,
# "coef": "",
# "moyenne": "?x?",
# }
return d
def etud_ue_results(
self,
etud: Identite,
ue: UniteEns,
decision_ue: dict,
etud_groups: list[GroupDescr] = None,
) -> dict:
"""dict synthèse résultats UE
etud_groups : liste des groupes, pour affichage du rang.
Si UE sport et étudiant non inscrit, renvoie dict vide.
"""
res = self.res
if (etud.id, ue.id) in self.res.dispense_ues:
return {}
if ue.type == UE_SPORT:
modimpls_spo = [
modimpl
for modimpl in res.formsemestre.modimpls_sorted
if modimpl.module.ue.type == UE_SPORT
]
# L'étudiant est-il inscrit à l'un des modules de l'UE bonus ?
if not any(res.modimpl_inscr_df.loc[etud.id][[m.id for m in modimpls_spo]]):
return {}
d = {
"id": ue.id,
"titre": ue.titre,
"numero": ue.numero,
"type": ue.type,
"color": ue.color,
"competence": None, # XXX TODO lien avec référentiel
"moyenne": None,
# Le bonus sport appliqué sur cette UE
"bonus": (
fmt_note(res.bonus_ues[ue.id][etud.id])
if res.bonus_ues is not None and ue.id in res.bonus_ues
else fmt_note(0.0)
),
"malus": fmt_note(res.malus[ue.id][etud.id]),
"capitalise": None, # "AAAA-MM-JJ" TODO #sco93
"ressources": self.etud_ue_mod_results(etud, ue, res.ressources),
"saes": self.etud_ue_mod_results(etud, ue, res.saes),
}
if self.prefs["bul_show_ects"]:
d["ECTS"] = {
"acquis": decision_ue.get("ects", 0.0),
"total": ue.ects or 0.0, # float même si non renseigné
}
if ue.type != UE_SPORT:
if self.prefs["bul_show_ue_rangs"]:
rangs, effectif = res.ue_rangs[ue.id]
rang = rangs[etud.id]
else:
rang, effectif = "", 0
d["moyenne"] = {
"value": fmt_note(res.etud_moy_ue[ue.id][etud.id]),
"min": fmt_note(res.etud_moy_ue[ue.id].min()),
"max": fmt_note(res.etud_moy_ue[ue.id].max()),
"moy": fmt_note(res.etud_moy_ue[ue.id].mean()),
"rang": rang,
"total": effectif, # nb etud avec note dans cette UE
"groupes": {},
}
if self.prefs["bul_show_ue_rangs"]:
for group in etud_groups:
if group.partition.bul_show_rank:
rang, effectif = self.res.get_etud_ue_rang(
ue.id, etud.id, group.id
)
d["moyenne"]["groupes"][group.id] = {
"value": rang,
"total": effectif,
}
else: # UE BONUS
d["modules"] = self.etud_mods_results(etud, modimpls_spo)
# ceci suppose que l'on a une seule UE bonus,
# en tous cas elles auront la même description
d["bonus_description"] = self.etud_bonus_description(etud.id)
return d
def etud_ues_capitalisees(self, etud: Identite) -> dict:
"""dict avec les UE capitalisees. la clé est l'acronyme d'UE, qui ne
peut donc être capitalisée qu'une seule fois (on prend la meilleure)"""
if not etud.id in self.res.validations.ue_capitalisees.index:
return {} # aucune capitalisation
d = {}
for _, ue_capitalisee in self.res.validations.ue_capitalisees.loc[
[etud.id]
].iterrows():
if codes_cursus.code_ue_validant(ue_capitalisee.code):
ue = db.session.get(UniteEns, ue_capitalisee.ue_id) # XXX cacher ?
# déjà capitalisé ? montre la meilleure
if ue.acronyme in d:
moy_cap = d[ue.acronyme]["moyenne_num"] or 0.0
if (not isinstance(moy_cap, float)) or (
(ue_capitalisee.moy_ue or 0.0) < moy_cap
):
continue # skip this duplicate UE
d[ue.acronyme] = {
"id": ue.id,
"ue_code": ue_capitalisee.ue_code,
"titre": ue.titre,
"numero": ue.numero,
"type": ue.type,
"color": ue.color,
"moyenne": fmt_note(ue_capitalisee.moy_ue), # arrondi en str
"moyenne_num": fmt_note(ue_capitalisee.moy_ue, keep_numeric=True),
"is_external": ue_capitalisee.is_external,
"date_capitalisation": ue_capitalisee.event_date,
"formsemestre_id": ue_capitalisee.formsemestre_id,
"bul_orig_url": (
url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
formsemestre_id=ue_capitalisee.formsemestre_id,
)
if ue_capitalisee.formsemestre_id
else None
),
"ressources": {}, # sans détail en BUT
"saes": {},
}
if self.prefs["bul_show_ects"]:
d[ue.acronyme]["ECTS"] = {
"acquis": ue.ects or 0.0, # toujours validée ici
"total": ue.ects or 0.0, # float même si non renseigné
}
return d
def etud_mods_results(self, etud, modimpls, version="long") -> dict:
"""dict synthèse résultats des modules indiqués,
avec évaluations de chacun (sauf si version == "short")
"""
res = self.res
d = {}
# etud_idx = self.etud_index[etud.id]
for modimpl in modimpls:
# mod_idx = self.modimpl_coefs_df.columns.get_loc(mi.id)
# # moyennes indicatives (moyennes de moyennes d'UE)
# try:
# moyennes_etuds = np.nan_to_num(
# np.nanmean(self.sem_cube[:, mod_idx, :], axis=1),
# copy=False,
# )
# except RuntimeWarning:
# # all nans in np.nanmean (sur certains etuds sans notes valides)
# pass
# try:
# moy_indicative_mod = np.nanmean(self.sem_cube[etud_idx, mod_idx])
# except RuntimeWarning: # all nans in np.nanmean
# pass
modimpl_results = res.modimpls_results[modimpl.id]
if res.modimpl_inscr_df[modimpl.id][etud.id]: # si inscrit
d[modimpl.module.code] = {
"id": modimpl.id,
"titre": modimpl.module.titre,
"code_apogee": modimpl.module.code_apogee,
"url": (
url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
if has_request_context()
else "na"
),
"moyenne": {
# # moyenne indicative de module: moyenne des UE,
# # ignorant celles sans notes (nan)
# "value": fmt_note(moy_indicative_mod),
# "min": fmt_note(moyennes_etuds.min()),
# "max": fmt_note(moyennes_etuds.max()),
# "moy": fmt_note(moyennes_etuds.mean()),
},
"evaluations": (
[
self.etud_eval_results(etud, e)
for e in modimpl.evaluations
if (e.visibulletin or version == "long")
and (e.id in modimpl_results.evaluations_etat)
and (
modimpl_results.evaluations_etat[e.id].is_complete
or self.prefs["bul_show_all_evals"]
)
]
if version != "short"
else []
),
}
return d
def etud_eval_results(self, etud, e: Evaluation) -> dict:
"dict resultats d'un étudiant à une évaluation"
# eval_notes est une pd.Series avec toutes les notes des étudiants inscrits
eval_notes = self.res.modimpls_results[e.moduleimpl_id].evals_notes[e.id]
notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna()
modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id]
try:
etud_ues_ids = self.res.etud_ues_ids(etud.id)
poids = {
ue.acronyme: modimpls_evals_poids[ue.id][e.id]
for ue in self.res.ues
if (ue.type != UE_SPORT) and (ue.id in etud_ues_ids)
}
except KeyError:
poids = collections.defaultdict(lambda: 0.0)
d = {
"id": e.id,
"coef": (
fmt_note(e.coefficient)
if e.evaluation_type == Evaluation.EVALUATION_NORMALE
else None
),
"date_debut": e.date_debut.isoformat() if e.date_debut else None,
"date_fin": e.date_fin.isoformat() if e.date_fin else None,
"description": e.description,
"evaluation_type": e.evaluation_type,
"note": (
{
"value": fmt_note(
eval_notes[etud.id],
note_max=e.note_max,
),
"min": fmt_note(notes_ok.min(), note_max=e.note_max),
"max": fmt_note(notes_ok.max(), note_max=e.note_max),
"moy": fmt_note(notes_ok.mean(), note_max=e.note_max),
}
if not e.is_blocked()
else {}
),
"poids": poids,
"url": (
url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e.id,
)
if has_request_context()
else "na"
),
# deprecated (supprimer avant #sco9.7)
"date": e.date_debut.isoformat() if e.date_debut else None,
"heure_debut": (
e.date_debut.time().isoformat("minutes") if e.date_debut else None
),
"heure_fin": e.date_fin.time().isoformat("minutes") if e.date_fin else None,
}
return d
def etud_bonus_description(self, etudid):
"""description du bonus affichée dans la section "UE bonus"."""
res = self.res
if res.bonus_ues is None or res.bonus_ues.shape[1] == 0:
return ""
bonus_vect = res.bonus_ues.loc[etudid]
if bonus_vect.nunique() > 1:
# détail UE par UE
details = [
f"{fmt_note(bonus_vect[ue.id])} sur {ue.acronyme}"
for ue in res.ues
if ue.type != UE_SPORT
and res.modimpls_in_ue(ue, etudid)
and ue.id in res.bonus_ues
and bonus_vect[ue.id] > 0.0
]
if details:
return "Bonus de " + ", ".join(details)
else:
return "" # aucun bonus
else:
return f"Bonus de {fmt_note(bonus_vect.iloc[0])}"
def bulletin_etud(
self,
etud: Identite,
force_publishing=False,
version="long",
) -> dict:
"""Le bulletin de l'étudiant dans ce semestre: dict pour la version JSON / HTML.
- version:
"long", "selectedevals": toutes les infos (notes des évaluations)
"short" : ne descend pas plus bas que les modules.
- Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai
(bulletins non publiés).
"""
if version not in scu.BULLETINS_VERSIONS_BUT:
raise ScoValueError("bulletin_etud: version de bulletin demandée invalide")
res = self.res
formsemestre = res.formsemestre
d = {
"version": "0",
"type": "BUT",
"date": datetime.datetime.utcnow().isoformat() + "Z",
"publie": not formsemestre.bul_hide_xml,
"etat_inscription": etud.inscription_etat(formsemestre.id),
"etudiant": etud.to_dict_bul(),
"formation": {
"id": formsemestre.formation.id,
"acronyme": formsemestre.formation.acronyme,
"titre_officiel": formsemestre.formation.titre_officiel,
"titre": formsemestre.formation.titre,
},
"formsemestre_id": formsemestre.id,
"options": sco_preferences.bulletin_option_affichage(
formsemestre, self.prefs
),
}
published = (not formsemestre.bul_hide_xml) or force_publishing
if not published or d["etat_inscription"] is False:
return d
nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT]
if formsemestre.formation.referentiel_competence is None:
etud_ues_ids = {ue.id for ue in res.ues if res.modimpls_in_ue(ue, etud.id)}
else:
etud_ues_ids = res.etud_ues_ids(etud.id)
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
etud_groups = sco_groups.get_etud_formsemestre_groups(
etud, formsemestre, only_to_show=True
)
semestre_infos = {
"etapes": [str(x.etape_apo) for x in formsemestre.etapes if x.etape_apo],
"date_debut": formsemestre.date_debut.isoformat(),
"date_fin": formsemestre.date_fin.isoformat(),
"annee_universitaire": formsemestre.annee_scolaire_str(),
"numero": formsemestre.semestre_id,
"inscription": "", # inutilisé mais nécessaire pour le js de Seb.
"groupes": [group.to_dict() for group in etud_groups],
}
if self.prefs["bul_show_abs"]:
semestre_infos["absences"] = {
"injustifie": nbabs - nbabsjust,
"total": nbabs,
"metrique": {
"H.": "Heure(s)",
"J.": "Journée(s)",
"1/2 J.": "1/2 Jour.",
}.get(sco_preferences.get_preference("assi_metrique")),
}
decisions_ues = self.res.get_etud_decisions_ue(etud.id) or {}
if self.prefs["bul_show_ects"]:
ects_tot = res.etud_ects_tot_sem(etud.id)
ects_acquis = res.get_etud_ects_valides(etud.id, decisions_ues)
semestre_infos["ECTS"] = {"acquis": ects_acquis, "total": ects_tot}
if sco_preferences.get_preference("bul_show_decision", formsemestre.id):
semestre_infos.update(
sco_bulletins_json.dict_decision_jury(etud, formsemestre)
)
if d["etat_inscription"] == scu.INSCRIT:
# moyenne des moyennes générales du semestre
semestre_infos["notes"] = {
"value": fmt_note(res.etud_moy_gen[etud.id]),
"min": fmt_note(res.etud_moy_gen.min()),
"moy": fmt_note(res.etud_moy_gen.mean()),
"max": fmt_note(res.etud_moy_gen.max()),
}
if self.prefs["bul_show_rangs"] and not np.isnan(res.etud_moy_gen[etud.id]):
# classement wrt moyenne générale, indicatif
semestre_infos["rang"] = {
"value": res.etud_moy_gen_ranks[etud.id],
"total": nb_inscrits,
"groupes": {},
}
# Rangs par groupes
for group in etud_groups:
if group.partition.bul_show_rank:
rang, effectif = self.res.get_etud_rang_group(etud.id, group.id)
semestre_infos["rang"]["groupes"][group.id] = {
"value": rang,
"total": effectif,
}
else:
semestre_infos["rang"] = {
"value": "-",
"total": nb_inscrits,
"groupes": {},
}
d.update(
{
"ressources": self.etud_mods_results(
etud, res.ressources, version=version
),
"saes": self.etud_mods_results(etud, res.saes, version=version),
"ues_capitalisees": self.etud_ues_capitalisees(etud),
"semestre": semestre_infos,
},
)
d_ues = {}
for ue in res.ues:
# si l'UE comporte des modules auxquels on est inscrit:
if (ue.type == UE_SPORT) or ue.id in etud_ues_ids:
ue_r = self.etud_ue_results(
etud,
ue,
decision_ue=decisions_ues.get(ue.id, {}),
etud_groups=etud_groups,
)
if ue_r: # exclu UE sport sans inscriptions
d_ues[ue.acronyme] = ue_r
d["ues"] = d_ues
else:
semestre_infos.update(
{
"notes": {
"value": "DEM",
"min": "",
"moy": "",
"max": "",
},
"rang": {"value": "DEM", "total": nb_inscrits},
}
)
d.update(
{
"semestre": semestre_infos,
"ressources": {},
"saes": {},
"ues": {},
"ues_capitalisees": {},
}
)
return d
def bulletin_etud_complet(self, etud: Identite, version="long") -> dict:
"""Bulletin dict complet avec toutes les infos pour les bulletins BUT pdf
(pas utilisé pour json/html)
Résultat compatible avec celui de sco_bulletins.formsemestre_bulletinetud_dict
"""
d = self.bulletin_etud(etud, version=version, force_publishing=True)
d["etudid"] = etud.id
d["etud"] = d["etudiant"]
d["etud"]["nomprenom"] = etud.nomprenom
d["etud"]["etat_civil"] = etud.etat_civil
d.update(self.res.sem)
etud_etat = self.res.get_etud_etat(etud.id)
d["filigranne"] = sco_bulletins_pdf.get_filigranne_apc(
etud_etat, self.prefs, etud.id, res=self.res
)
if etud_etat == scu.DEMISSION:
d["demission"] = "(Démission)"
elif etud_etat == DEF:
d["demission"] = "(Défaillant)"
else:
d["demission"] = ""
# --- Absences
d["nbabs"], d["nbabsjust"] = self.res.formsemestre.get_abs_count(etud.id)
# --- Decision Jury
infos, _ = sco_bulletins.etud_descr_situation_semestre(
etud.id,
self.res.formsemestre,
fmt="html",
show_date_inscr=self.prefs["bul_show_date_inscr"],
show_decisions=self.prefs["bul_show_decision"],
show_uevalid=self.prefs["bul_show_uevalid"],
show_mention=self.prefs["bul_show_mention"],
)
d.update(infos)
# --- Rangs
d["rang_nt"] = (
f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
)
d["rang_txt"] = "Rang " + d["rang_nt"]
d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"]))
return d