##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury BUT et classiques: table recap annuelle et liens saisie
"""
import collections
import time
import numpy as np
from flask import g, url_for
from app.but import jury_but
from app.but.jury_but import (
DecisionsProposeesAnnee,
DecisionsProposeesRCUE,
DecisionsProposeesUE,
)
from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat
from app.comp import res_sem
from app.models import UniteEns
from app.models.etudiants import Identite
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
from app.models.formsemestre import FormSemestre
from app.scodoc import html_sco_header
from app.scodoc.sco_codes_parcours import (
BUT_BARRE_RCUE,
BUT_RCUE_SUFFISANT,
)
from app.scodoc import sco_formsemestre_status
from app.scodoc import sco_pvjury
from app.scodoc import sco_utils as scu
from app.tables.recap import RowRecap, TableRecap
class TableJury(TableRecap):
"""Cette table recap reprend les colonnes du tableau recap, sauf les évaluations,
et ajoute:
Pour le BUT:
- les RCUEs (moyenne et code décision)
Pour toutes les formations:
- les codes de décisions jury sur les UEs
- le lien de saisie ou modif de la décision de jury
"""
def __init__(self, *args, row_class: str = None, read_only=True, **kwargs):
super().__init__(
*args, row_class=row_class or RowJury, finalize=False, **kwargs
)
# redéclare pour VSCode
self.rows: list["RowJury"] = self.rows
self.res: NotesTableCompat = self.res
self.read_only = read_only
# Stats jury: fréquence de chaque code enregistré
self.freq_codes_annuels = collections.Counter()
# Ajout colonnes spécifiques à la table jury:
if self.res.is_apc:
self.add_rcues()
self.add_jury()
self.add_groups_header()
# Termine la table
self.finalize()
def add_rcues(self):
"""Ajoute les colonnes indiquant le nb de RCUEs et chaque RCUE
pour tous les étudiants de la table.
La table contient des rows avec la clé etudid.
Les colonnes ont la classe css "rcue".
"""
self.insert_group("rcue", before="col_ues_validables")
for row in self.rows:
etud: Identite = row.etud
deca = row.deca
if deca.code_valide:
self.freq_codes_annuels[deca.code_valide] += 1
row.add_nb_rcues_cell()
# --- Les RCUEs
for rcue in deca.rcues_annee:
dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau
row.add_rcue_cols(dec_rcue)
def add_jury(self):
"""Ajoute la colonne code jury et le lien.
Le code jury est celui du semestre: cette colonne n'est montrée
que pour les formations classiques, ce code n'est pas utilisé en BUT.
"""
res = self.res
for row in self.rows:
etud = row.etud
if not res.is_apc:
# formations classiques: code semestre
if res.validations:
dec_sem = res.validations.decisions_jury.get(etud.id)
jury_code_sem = dec_sem["code"] if dec_sem else ""
else:
jury_code_sem = ""
row.add_cell(
"jury_code_sem", "Jury", jury_code_sem, group="jury_code_sem"
)
self.foot_title_row.cells["jury_code_sem"].target_attrs[
"title"
] = """Code jury sur le semestre"""
row.add_cell(
"jury_link",
"",
f"""{("modifier" if res.validations and res.validations.has_decision(etud) else "saisir")
if res.formsemestre.etat else "voir"} décisions""",
group="col_jury_link",
target=url_for(
"notes.formsemestre_validation_etud_form",
scodoc_dept=g.scodoc_dept,
formsemestre_id=res.formsemestre.id,
etudid=etud.id,
),
target_attrs={"class": "stdlink"},
)
class RowJury(RowRecap):
"Ligne de la table saisie jury"
def __init__(self, table: TableJury, etud: Identite, *args, **kwargs):
self.table: TableJury = table
super().__init__(table, etud, *args, **kwargs)
if table.res.is_apc:
# Conserve le deca de cet étudiant:
self.deca = jury_but.DecisionsProposeesAnnee(
self.etud, self.table.res.formsemestre
)
def add_nb_rcues_cell(self):
"cell avec nb niveaux validables / total"
deca = self.deca
classes = ["col_rcue", "col_rcues_validables"]
if deca.nb_rcues_under_8 > 0:
classes.append("moy_ue_warning")
elif deca.nb_validables < deca.nb_competences:
classes.append("moy_ue_inf")
else:
classes.append("moy_ue_valid")
if len(deca.rcues_annee) > 0:
# permet un tri par nb de niveaux validables + moyenne gen indicative S_pair
if deca.res_pair and deca.etud.id in deca.res_pair.etud_moy_gen:
moy = deca.res_pair.etud_moy_gen[deca.etud.id]
if np.isnan(moy):
moy_gen_d = "x"
else:
moy_gen_d = f"{int(moy*1000):05}"
else:
moy_gen_d = "x"
order = f"{deca.nb_validables:04d}-{moy_gen_d}"
else:
# étudiants sans RCUE: pas de semestre impair, ...
# les classe à la fin
order = f"{deca.nb_validables:04d}-00000-{deca.etud.sort_key}"
self.add_cell(
"rcues_validables",
"RCUEs",
f"""{deca.nb_validables}/{deca.nb_competences}"""
+ ((" " + scu.EMO_WARNING) if deca.nb_rcues_under_8 > 0 else ""),
raw_content=f"""{deca.nb_validables}/{deca.nb_competences}""",
group="rcue",
classes=classes,
data={"order": order},
)
def add_ue_cols(self, ue: UniteEns, ue_status: dict, col_group: str = None):
"Ajoute 2 colonnes: moyenne d'UE et code jury"
# table recap standard (mais avec group différent)
super().add_ue_cols(ue, ue_status, col_group=col_group or "col_ue")
dues = self.table.res.get_etud_decision_ues(self.etud.id)
if not dues:
return
due = dues.get(ue.id)
if not due:
return
col_id = f"moy_ue_{ue.id}_code"
self.add_cell(
col_id,
"", # titre vide
due["code"],
raw_content=due["code"],
group="col_ue",
classes=["recorded_code"],
column_classes={"col_jury", "col_ue_code"},
target_attrs={
"title": f"""enregistrée le {due['event_date']}, {
(due["ects"] or 0):.3g} ECTS."""
},
)
def add_rcue_cols(self, dec_rcue: DecisionsProposeesRCUE):
"2 cells: moyenne du RCUE, code enregistré"
self.table.group_titles["rcue"] = "RCUEs en cours"
rcue = dec_rcue.rcue
col_id = f"moy_rcue_{rcue.ue_1.niveau_competence_id}" # le niveau_id
note_class = ""
val = rcue.moy_rcue
if isinstance(val, float):
if val < BUT_BARRE_RCUE:
note_class = "moy_ue_inf"
elif val >= BUT_BARRE_RCUE:
note_class = "moy_ue_valid"
if val < BUT_RCUE_SUFFISANT:
note_class = "moy_ue_warning" # notes très basses
self.add_cell(
col_id,
f"
{rcue.ue_1.acronyme}
{rcue.ue_2.acronyme}
",
self.table.fmt_note(val),
raw_content=val,
group="rcue",
classes=[note_class],
column_classes={"col_rcue"},
)
self.add_cell(
col_id + "_code",
f"{rcue.ue_1.acronyme}
{rcue.ue_2.acronyme}
",
dec_rcue.code_valide or "",
group="rcue",
classes=["col_rcue_code", "recorded_code"],
column_classes={"col_rcue"},
)
def formsemestre_saisie_jury_but(
formsemestre: FormSemestre,
read_only: bool = False,
selected_etudid: int = None,
mode="jury",
) -> str:
"""formsemestre est un semestre PAIR
Si readonly, ne montre pas le lien "saisir la décision"
=> page html complète
Si mode == "recap", table recap des codes, sans liens de saisie.
"""
# pour chaque etud de res2 trié
# S1: UE1, ..., UEn
# S2: UE1, ..., UEn
#
# UE1_s1, UE1_s2, moy_rcue, UE2... , Nbrcue_validables, Nbrcue<8, passage_de_droit, valide_moitie_rcue
#
# Pour chaque etud de res2 trié
# DecisionsProposeesAnnee(etud, formsemestre2)
# Pour le 1er etud, faire un check_ues_ready_jury(self) -> page d'erreur
# -> rcue .ue_1, .ue_2 -> stroe moy ues, rcue.moy_rcue, etc
if formsemestre.formation.referentiel_competence is None:
raise ScoNoReferentielCompetences(formation=formsemestre.formation)
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
table = TableJury(
res,
convert_values=True,
mode_jury=True,
read_only=read_only,
classes=[
"table_jury_but_bilan" if mode == "recap" else "",
"table_recap",
"apc",
"jury table_jury_but",
],
selected_row_id=selected_etudid,
)
if table.is_empty():
return (
''
)
table.data["filename"] = scu.sanitize_filename(
f"""jury-but-{formsemestre.titre_num()}-{time.strftime("%Y-%m-%d")}"""
)
table_html = table.html()
H = [
html_sco_header.sco_header(
page_title=f"{formsemestre.sem_modalite()}: jury BUT",
no_side_bar=True,
init_qtip=True,
javascripts=["js/etud_info.js", "js/table_recap.js"],
),
sco_formsemestre_status.formsemestre_status_head(
formsemestre_id=formsemestre.id
),
]
if mode == "recap":
H.append(
f"""Décisions de jury enregistrées pour les étudiants de ce semestre
"""
)
H.append(
f"""
{table_html}
Nb d'étudiants avec décision annuelle:
{sum(table.freq_codes_annuels.values())} / {len(table)}
Codes annuels octroyés:
"""
)
for code in sorted(table.freq_codes_annuels.keys()):
H.append(
f"""
{code} |
{table.freq_codes_annuels[code]} |
{
(100*table.freq_codes_annuels[code] / len(table)):2.1f}%
|
"""
)
H.append(
f"""
{html_sco_header.sco_footer()}
"""
)
return "\n".join(H)
def build_table_jury_but_html(
filename: str, rows, titles, column_ids, selected_etudid: int = None, klass=""
) -> str:
"""assemble la table html"""
footer_rows = [] # inutilisé pour l'instant
H = [
f""""""
]
# header
H.append(
f"""
{scu.gen_row(column_ids, titles, "th")}
"""
)
# body
H.append("")
for row in rows:
H.append(f"{scu.gen_row(column_ids, row, selected_etudid=selected_etudid)}\n")
H.append("\n")
# footer
H.append("")
idx_last = len(footer_rows) - 1
for i, row in enumerate(footer_rows):
H.append(f'{scu.gen_row(column_ids, row, "th" if i == idx_last else "td")}\n')
H.append(
"""
"""
)
return "".join(H)
class RowCollector:
"""Une ligne de la table"""
def __init__(
self,
cells: dict = None,
titles: dict = None,
convert_values=True,
column_classes: dict = None,
):
self.titles = titles
self.row = cells or {} # col_id : str
self.column_classes = column_classes # col_id : str, css class
self.idx = 0
self.last_etud_cell_idx = 0
if convert_values:
self.fmt_note = scu.fmt_note
else:
self.fmt_note = lambda x: x
def __setitem__(self, key, value):
self.row[key] = value
def __getitem__(self, key):
return self.row[key]
def get_row_dict(self):
"La ligne, comme un dict"
# create empty cells
for col_id in self.titles:
if col_id not in self.row:
self.row[col_id] = ""
klass = self.column_classes.get(col_id)
if klass:
self.row[f"_{col_id}_class"] = klass
return self.row
def add_cell(
self,
col_id: str,
title: str,
content: str,
classes: str = "",
idx: int = None,
column_class="",
):
"""Add a row to our table. classes is a list of css class names"""
self.idx = idx if idx is not None else self.idx
self.row[col_id] = content
if classes:
self.row[f"_{col_id}_class"] = classes + f" c{self.idx}"
if not col_id in self.titles:
self.titles[col_id] = title
self.titles[f"_{col_id}_col_order"] = self.idx
if classes:
self.titles[f"_{col_id}_class"] = classes
self.column_classes[col_id] = column_class
self.idx += 1
def get_jury_but_table( # XXX A SUPPRIMER apres avoir recupéré les stats
formsemestre2: FormSemestre, read_only: bool = False, mode="jury", with_links=True
) -> tuple[list[dict], list[str], list[str], dict]:
"""Construit la table des résultats annuels pour le jury BUT
=> rows_dict, titles, column_ids, jury_stats
où jury_stats est un dict donnant des comptages sur le jury.
"""
# /////// XXX /////// XXX //////
titles = {} # column_id : title
jury_stats = {
"nb_etuds": len(formsemestre2.etuds_inscriptions),
"codes_annuels": collections.Counter(),
}
table = TableJury(res2, mode_jury=True)
for etudid in formsemestre2.etuds_inscriptions:
etud: Identite = Identite.query.get(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre2)
# XXX row = RowCollector(titles=titles, column_classes=column_classes)
row = RowJury(table, etudid)
table.add_row(row)
row.add_etud(etud)
# --- Nombre de niveaux
row.add_nb_rcues_cell(deca)
# --- Les RCUEs
for rcue in deca.rcues_annee:
dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau
row.add_ue_cells(deca.decisions_ues[rcue.ue_1.id])
row.add_ue_cells(deca.decisions_ues[rcue.ue_2.id])
row.add_rcue_cells(dec_rcue)
# --- Les ECTS validés
ects_valides = 0.0
if deca.res_impair:
ects_valides += deca.res_impair.get_etud_ects_valides(etudid)
if deca.res_pair:
ects_valides += deca.res_pair.get_etud_ects_valides(etudid)
row.add_cell(
"ects_annee",
"ECTS",
f"""{int(ects_valides)}""",
"col_code_annee",
)
# --- Le code annuel existant
row.add_cell(
"code_annee",
"Année",
f"""{deca.code_valide or ''}""",
"col_code_annee",
)
if deca.code_valide:
jury_stats["codes_annuels"][deca.code_valide] += 1
# --- Le lien de saisie
if mode != "recap" and with_links:
row.add_cell(
"lien_saisie",
"",
f"""
{"voir" if read_only else ("modif." if deca.code_valide else "saisie")}
décision
"""
if deca.inscription_etat == scu.INSCRIT
else deca.inscription_etat,
"col_lien_saisie_but",
)
rows.append(row)
rows_dict = [row.get_row_dict() for row in rows]
if len(rows_dict) > 0:
col_idx = res2.recap_add_partitions(
rows_dict, titles, col_idx=row.last_etud_cell_idx + 1
)
res2.recap_add_cursus(rows_dict, titles, col_idx=col_idx + 1)
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))
rows_dict.sort(key=lambda row: row["_nom_disp_order"])
return rows_dict, titles, column_ids, jury_stats
def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]:
"""Liste des résultats jury BUT sous forme de dict, pour API"""
if formsemestre.formation.referentiel_competence is None:
# pas de ref. comp., donc pas de decisions de jury (ne lance pas d'exception)
return []
dpv = sco_pvjury.dict_pvjury(formsemestre.id)
rows = []
for etudid in formsemestre.etuds_inscriptions:
rows.append(get_jury_but_etud_result(formsemestre, dpv, etudid))
return rows
def get_jury_but_etud_result(
formsemestre: FormSemestre, dpv: dict, etudid: int
) -> dict:
"""Résultats de jury d'un étudiant sur un semestre pair de BUT"""
etud: Identite = Identite.query.get(etudid)
dec_etud = dpv["decisions_dict"][etudid]
if formsemestre.formation.is_apc():
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
else:
deca = None
row = {
"etudid": etud.id,
"code_nip": etud.code_nip,
"code_ine": etud.code_ine,
"is_apc": dpv["is_apc"], # BUT ou classic ?
"etat": dec_etud["etat"], # I ou D ou DEF
"nb_competences": deca.nb_competences if deca else 0,
}
# --- Les RCUEs
rcue_list = []
if deca:
for rcue in deca.rcues_annee:
dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau
dec_ue1 = deca.decisions_ues[rcue.ue_1.id]
dec_ue2 = deca.decisions_ues[rcue.ue_2.id]
rcue_dict = {
"ue_1": {
"ue_id": rcue.ue_1.id,
"moy": None
if (dec_ue1.moy_ue is None or np.isnan(dec_ue1.moy_ue))
else dec_ue1.moy_ue,
"code": dec_ue1.code_valide,
},
"ue_2": {
"ue_id": rcue.ue_2.id,
"moy": None
if (dec_ue2.moy_ue is None or np.isnan(dec_ue2.moy_ue))
else dec_ue2.moy_ue,
"code": dec_ue2.code_valide,
},
"moy": rcue.moy_rcue,
"code": dec_rcue.code_valide,
}
rcue_list.append(rcue_dict)
row["rcues"] = rcue_list
# --- Les UEs
ue_list = []
if dec_etud["decisions_ue"]:
for ue_id, ue_dec in dec_etud["decisions_ue"].items():
ue_dict = {
"ue_id": ue_id,
"code": ue_dec["code"],
"ects": ue_dec["ects"],
}
ue_list.append(ue_dict)
row["ues"] = ue_list
# --- Le semestre (pour les formations classiques)
if dec_etud["decision_sem"]:
row["semestre"] = {"code": dec_etud["decision_sem"].get("code")}
else:
row["semestre"] = {} # APC, ...
# --- Autorisations
row["autorisations"] = dec_etud["autorisations"]
return row