############################################################################## # ScoDoc # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## """Génération bulletin BUT """ import collections import datetime import pandas as pd import numpy as np from flask import g, has_request_context, url_for from app import db from app.comp.moy_mod import ModuleImplResults from app.comp.res_but import ResultatsSemestreBUT from app.models import Evaluation, FormSemestre, Identite, ModuleImpl 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_str(), "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_list_modimpl_evaluations( etud, modimpl, modimpl_results, version ) if version != "short" else [] ), } return d def etud_list_modimpl_evaluations( self, etud: Identite, modimpl: ModuleImpl, modimpl_results: ModuleImplResults, version: str, ) -> list[dict]: """Liste des résultats aux évaluations de ce modimpl à montrer pour cet étudiant""" evaluation: Evaluation eval_results = [] for evaluation in modimpl.evaluations: if ( (evaluation.visibulletin or version == "long") and (evaluation.id in modimpl_results.evaluations_etat) and ( modimpl_results.evaluations_etat[evaluation.id].is_complete or self.prefs["bul_show_all_evals"] ) ): eval_notes = self.res.modimpls_results[modimpl.id].evals_notes[ evaluation.id ] if (evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE) or ( not np.isnan(eval_notes[etud.id]) ): eval_results.append( self.etud_eval_results(etud, evaluation, eval_notes) ) return eval_results def etud_eval_results( self, etud: Identite, evaluation: Evaluation, eval_notes: pd.DataFrame ) -> dict: "dict resultats d'un étudiant à une évaluation" # eval_notes est une pd.Series avec toutes les notes des étudiants inscrits notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna() modimpls_evals_poids = self.res.modimpls_evals_poids[evaluation.moduleimpl_id] try: etud_ues_ids = self.res.etud_ues_ids(etud.id) poids = { ue.acronyme: modimpls_evals_poids[ue.id][evaluation.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": evaluation.id, "coef": ( fmt_note(evaluation.coefficient) if evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE else None ), "date_debut": ( evaluation.date_debut.isoformat() if evaluation.date_debut else None ), "date_fin": ( evaluation.date_fin.isoformat() if evaluation.date_fin else None ), "description": evaluation.description, "evaluation_type": evaluation.evaluation_type, "note": ( { "value": fmt_note( eval_notes[etud.id], note_max=evaluation.note_max, ), "min": fmt_note(notes_ok.min(), note_max=evaluation.note_max), "max": fmt_note(notes_ok.max(), note_max=evaluation.note_max), "moy": fmt_note(notes_ok.mean(), note_max=evaluation.note_max), } if not evaluation.is_blocked() else {} ), "poids": poids, "url": ( url_for( "notes.evaluation_listenotes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id, ) if has_request_context() else "na" ), # deprecated (supprimer avant #sco9.7) "date": ( evaluation.date_debut.isoformat() if evaluation.date_debut else None ), "heure_debut": ( evaluation.date_debut.time().isoformat("minutes") if evaluation.date_debut else None ), "heure_fin": ( evaluation.date_fin.time().isoformat("minutes") if evaluation.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 sur la passerelle). """ 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) nbabsnj, nbabsjust, nbabs = 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": nbabsnj, "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["nbabsjust"], d["nbabs"] = 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