diff --git a/app/comp/res_but.py b/app/comp/res_but.py index 3ffed67004..9063451263 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -15,10 +15,12 @@ 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.etudiants import Identite from app.models.moduleimpls import ModuleImpl from app.models.ues import UniteEns from app.scodoc import sco_preferences from app.scodoc.sco_codes_parcours import UE_SPORT +import app.scodoc.sco_utils as scu class ResultatsSemestreBUT(NotesTableCompat): @@ -32,6 +34,10 @@ class ResultatsSemestreBUT(NotesTableCompat): def __init__(self, formsemestre): super().__init__(formsemestre) + self.modimpl_coefs_df = None + """DataFrame, row UEs(sans bonus), cols modimplid, value coef""" + self.sem_cube = None + """ndarray (etuds x modimpl x ue)""" if not self.load_cached(): t0 = time.time() @@ -145,15 +151,228 @@ class ResultatsSemestreBUT(NotesTableCompat): """ return self.modimpl_coefs_df.loc[ue.id].sum() - def modimpls_in_ue(self, ue_id, etudid) -> list[ModuleImpl]: + def modimpls_in_ue(self, ue_id, etudid, with_bonus=True) -> list[ModuleImpl]: """Liste des modimpl ayant des coefs non nuls vers cette UE - et auxquels l'étudiant est inscrit. + et auxquels l'étudiant est inscrit. Inclus modules bonus le cas échéant. """ - # sert pour l'affichage ou non de l'UE sur le bulletin + # sert pour l'affichage ou non de l'UE sur le bulletin et la table recap coefs = self.modimpl_coefs_df # row UE, cols modimpl - return [ + modimpls = [ modimpl for modimpl in self.formsemestre.modimpls_sorted if (coefs[modimpl.id][ue_id] != 0) 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 + + def get_table_moyennes_triees(self, convert_values=False) -> list: + """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_, ..., les moyennes d'UE + moy_res__, ... les moyennes de ressources dans l'UE + moy_sae__, ... les moyennes de SAE dans l'UE + + On ajoute aussi des attributs: + - pour les lignes: + _css_row_class (inutilisé pour le monent) + __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_ + __order : clé de tri + """ + + def fmt_note(x): + return scu.fmt_note(x) if convert_values else x + + barre_moy = ( + self.formsemestre.formation.get_parcours().BARRE_MOY - scu.NOTES_TOLERANCE + ) + barre_valid_ue = self.formsemestre.formation.get_parcours().NOTES_BARRE_VALID_UE + NO_NOTE = "-" # contenu des cellules sans notes + rows = [] + titles = {"rang": "Rg"} # column_id : title + + def add_cell( + row: dict, col_id: str, title: str, content: str, classes: str = "" + ): + "Add a row to our table. classes is a list of css class names" + row[col_id] = content + if classes: + row[f"_{col_id}_class"] = classes + if not col_id in titles: + titles[col_id] = title + if classes: + titles[f"_{col_id}_class"] = classes + + etuds_inscriptions = self.formsemestre.etuds_inscriptions + ues = self.formsemestre.query_ues(with_sport=True) # avec bonus + modimpl_ids = set() # modimpl effectivement présents dans la table + for etudid in etuds_inscriptions: + etud = Identite.query.get(etudid) + row = {"etudid": etudid} + # --- Rang + add_cell(row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang") + row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}" + # --- Identité étudiant + add_cell(row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail") + add_cell(row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail") + add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail") + add_cell(row, "nom_short", "Nom", etud.nom_short, "identite_court") + # --- Moyenne générale + 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_inf" + add_cell( + row, + "moy_gen", + "Moy", + fmt_note(moy_gen), + "col_moy_gen" + note_class, + ) + # --- Moyenne d'UE + for ue in [ue for ue in ues if ue.type != UE_SPORT]: + 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" + add_cell( + row, + col_id, + ue.acronyme, + fmt_note(val), + "col_ue" + note_class, + ) + # Les moyennes des ressources et SAÉs dans cette UE + for modimpl in self.modimpls_in_ue(ue.id, 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 + 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: + val = "" + + col_id = ( + f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" + ) + add_cell( + row, + col_id, + modimpl.module.code, + fmt_note(val), + # class col_res mod_ue_123 + f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}", + ) + modimpl_ids.add(modimpl.id) + + rows.append(row) + + # tri par rang croissant + rows.sort(key=lambda e: e["_rang_order"]) + + # INFOS POUR FOOTER + bottom_infos = self._bottom_infos( + [ue for ue in ues if ue.type != UE_SPORT], modimpl_ids, fmt_note + ) + + # --- TABLE FOOTER: ECTS, moyennes, min, max... + footer_rows = [] + for bottom_line in bottom_infos: + row = bottom_infos[bottom_line] + # Cases vides à styler: + row["moy_gen"] = row.get("moy_gen", "") + row["_moy_gen_class"] = "col_moy_gen" + # titre de la ligne: + row["nom_disp"] = row["nom_short"] = bottom_line.capitalize() + row["_tr_class"] = bottom_line.lower() + footer_rows.append(row) + return ( + rows, + footer_rows, + titles, + [title for title in titles if not title.startswith("_")], + ) + + def _bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict: + """Les informations à mettre en bas de la table: min, max, moy, ECTS""" + bottom_infos = { # { key : row } avec key = min, max, moy, coef + "min": {}, + "max": {}, + "moy": {}, + "coef": {}, + } + # --- ECTS + row = {} + for ue in ues: + row[f"moy_ue_{ue.id}"] = ue.ects + row[f"_moy_ue_{ue.id}_class"] = "col_ue" + # style cases vides pour borders verticales + bottom_infos["coef"][f"moy_ue_{ue.id}"] = "" + bottom_infos["coef"][f"_moy_ue_{ue.id}_class"] = "col_ue" + row["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]) + row["_moy_gen_class"] = "col_moy_gen" + bottom_infos["ects"] = row + + # --- MIN, MAX, MOY + row_min, row_max, row_moy = {}, {}, {} + 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 [ue for ue in ues if ue.type != UE_SPORT]: + col_id = f"moy_ue_{ue.id}" + row_min[col_id] = fmt_note(self.etud_moy_ue[ue.id].min()) + row_max[col_id] = fmt_note(self.etud_moy_ue[ue.id].max()) + row_moy[col_id] = fmt_note(self.etud_moy_ue[ue.id].mean()) + row_min[f"_{col_id}_class"] = "col_ue" + row_max[f"_{col_id}_class"] = "col_ue" + row_moy[f"_{col_id}_class"] = "col_ue" + + for modimpl in self.formsemestre.modimpls_sorted: + if modimpl.id in modimpl_ids: + col_id = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" + bottom_infos["coef"][col_id] = fmt_note( + self.modimpl_coefs_df[modimpl.id][ue.id] + ) + i = self.modimpl_coefs_df.columns.get_loc(modimpl.id) + j = self.modimpl_coefs_df.index.get_loc(ue.id) + notes = self.sem_cube[:, i, j] + row_min[col_id] = fmt_note(np.nanmin(notes)) + row_max[col_id] = fmt_note(np.nanmax(notes)) + row_moy[col_id] = fmt_note(np.nanmean(notes)) + + bottom_infos["min"] = row_min + bottom_infos["max"] = row_max + bottom_infos["moy"] = row_moy + return bottom_infos diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 65c0701feb..c3136721e5 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -123,6 +123,11 @@ class Identite(db.Model): r.append("-".join([x.lower().capitalize() for x in fields])) return " ".join(r) + @property + def nom_short(self): + "Nom et début du prénom pour table recap: 'DUPONT Pi.'" + return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}." + @cached_property def sort_key(self) -> tuple: "clé pour tris par ordre alphabétique" diff --git a/app/models/modules.py b/app/models/modules.py index 5a5f476187..67ff3de0df 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -33,7 +33,7 @@ class Module(db.Model): numero = db.Column(db.Integer) # ordre de présentation # id de l'element pedagogique Apogee correspondant: code_apogee = db.Column(db.String(APO_CODE_STR_LEN)) - # Type: ModuleType: DEFAULT, MALUS, RESSOURCE, MODULE_SAE (enum) + # Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum) module_type = db.Column(db.Integer, nullable=False, default=0, server_default="0") # Relations: modimpls = db.relationship("ModuleImpl", backref="module", lazy="dynamic") @@ -76,6 +76,11 @@ class Module(db.Model): def type_name(self): return scu.MODULE_TYPE_NAMES[self.module_type] + def type_abbrv(self): + """ "mod", "malus", "res", "sae" + (utilisées pour style css)""" + return scu.ModuleType.get_abbrev(self.module_type) + def set_ue_coef(self, ue, coef: float) -> None: """Set coef module vers cette UE""" self.update_ue_coef_dict({ue.id: coef}) diff --git a/app/models/ues.py b/app/models/ues.py index 2bed88a383..518bd72192 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -4,7 +4,6 @@ from app import db from app.models import APO_CODE_STR_LEN from app.models import SHORT_STR_LEN -from app.scodoc import notesdb as ndb from app.scodoc import sco_utils as scu diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index c85ee67c9f..1dbed2bb74 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -38,6 +38,7 @@ from flask import make_response, url_for from app import log from app.but import bulletin_but from app.comp import res_sem +from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_common import NotesTableCompat from app.models import FormSemestre from app.models.etudiants import Identite @@ -108,7 +109,7 @@ def formsemestre_recapcomplet( page_title="Récapitulatif", no_side_bar=True, init_qtip=True, - javascripts=["js/etud_info.js"], + javascripts=["js/etud_info.js", "js/table_recap.js"], ), sco_formsemestre_status.formsemestre_status_head( formsemestre_id=formsemestre_id @@ -223,19 +224,28 @@ def do_formsemestre_recapcomplet( force_publishing=True, ): """Calcule et renvoie le tableau récapitulatif.""" - data, filename, format = make_formsemestre_recapcomplet( - formsemestre_id=formsemestre_id, - format=format, - hidemodules=hidemodules, - hidebac=hidebac, - xml_nodate=xml_nodate, - modejury=modejury, - sortcol=sortcol, - xml_with_decisions=xml_with_decisions, - disable_etudlink=disable_etudlink, - rank_partition_id=rank_partition_id, - force_publishing=force_publishing, - ) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + if ( + formsemestre.formation.is_apc() + and format not in ("xml", "json") + and not modejury + ): + data, filename = make_formsemestre_recapcomplet_apc(formsemestre, format=format) + else: + data, filename, format = make_formsemestre_recapcomplet( + formsemestre_id=formsemestre_id, + format=format, + hidemodules=hidemodules, + hidebac=hidebac, + xml_nodate=xml_nodate, + modejury=modejury, + sortcol=sortcol, + xml_with_decisions=xml_with_decisions, + disable_etudlink=disable_etudlink, + rank_partition_id=rank_partition_id, + force_publishing=force_publishing, + ) + # --- if format == "xml" or format == "html": return data elif format == "csv": @@ -1004,3 +1014,59 @@ def formsemestres_bulletins(annee_scolaire): jslist.append(J) return scu.sendJSON(jslist) + + +def _gen_cell(key: str, row: dict, elt="td"): + "html table cell" + klass = row.get(f"_{key}_class") + attrs = f'class="{klass}"' if klass else "" + order = row.get("_{key}_order") + if order: + attrs += f' data-order="{order}"' + return f'<{elt} {attrs}>{row.get(key, "")}' + + +def _gen_row(keys: list[str], row, elt="td"): + klass = row.get("_tr_class") + tr_class = f'class="{klass}"' if klass else "" + return f'{"".join([_gen_cell(key, row, elt) for key in keys])}' + + +def make_formsemestre_recapcomplet_apc(formsemestre: FormSemestre, format="html"): + """Construit table recap pour le BUT + Return: data, filename + """ + res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) + rows, footer_rows, titles, column_ids = res.get_table_moyennes_triees( + convert_values=True + ) + H = ['
'] + # header + H.append( + f""" + + {_gen_row(column_ids, titles, "th")} + + """ + ) + # body + H.append("") + for row in rows: + H.append(f"{_gen_row(column_ids, row)}\n") + H.append("\n") + # footer + H.append("") + for row in footer_rows: + H.append(f"{_gen_row(column_ids, row)}\n") + H.append( + f""" + {_gen_row(column_ids, titles, "th")} + +
+
+ """ + ) + return ( + "".join(H), + f'recap-{formsemestre.titre_num().replace(" ", "_")}-{time.strftime("%d-%m-%Y")}', + ) # suffix ? diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index a0bb78c610..216a0b80d5 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -87,6 +87,19 @@ class ModuleType(IntEnum): RESSOURCE = 2 # BUT SAE = 3 # BUT + @classmethod + def get_abbrev(cls, code) -> str: + """Chaine abregée décrivant le type de module à partir du code integer: + "mod", "malus", "res", "sae" + (utilisées pour style css) + """ + return { + ModuleType.STANDARD: "mod", + ModuleType.MALUS: "malus", + ModuleType.RESSOURCE: "res", + ModuleType.SAE: "sae", + }.get(code, "???") + MODULE_TYPE_NAMES = { ModuleType.STANDARD: "Module", diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index a208164626..d36cf47c4a 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -3237,4 +3237,46 @@ table.dataTable tr.gt_lastrow th { } table.dataTable td.etudinfo, table.dataTable td.group { text-align: left; +} +/* Nouveau tableau recap */ +div.table_recap table.table_recap { + width: auto; +} +table.table_recap .identite_court { + white-space:nowrap; + text-align: left; +} +table.table_recap .rang { + white-space:nowrap; + text-align: right; +} +table.table_recap .col_ue, table.table_recap .col_moy_gen { + border-left: 1px solid blue; +} +table.table_recap tfoot th, table.table_recap thead th { + text-align: left; + padding-left: 10px !important; +} +table.table_recap td.moy_inf { + font-weight: bold; + color: rgb(255,0,0); +} +table.table_recap td.moy_ue_valid { + font-weight: bold; + color: rgb(0,140,0); +} +table.table_recap tr.ects td { + color: rgb(160, 86, 3); + font-weight: bold; + border-bottom: 1px solid blue; +} +table.table_recap tr.coef td { + font-style: italic; + color: #9400d3; +} +table.table_recap tr.coef td, table.table_recap tr.min td, +table.table_recap tr.max td, table.table_recap tr.moy td { + font-size: 80%; + padding-top: 3px; + padding-bottom: 3px; } \ No newline at end of file diff --git a/app/static/js/table_recap.js b/app/static/js/table_recap.js new file mode 100644 index 0000000000..8f81999ce1 --- /dev/null +++ b/app/static/js/table_recap.js @@ -0,0 +1,67 @@ +// Tableau recap notes +$(function () { + $(function () { + $('table.table_recap').DataTable( + { + paging: false, + searching: true, + info: false, + autoWidth: false, + fixedHeader: { + header: true, + footer: true + }, + orderCellsTop: true, // cellules ligne 1 pour tri + aaSorting: [], // Prevent initial sorting + colReorder: true, + "columnDefs": [ + { + // cache le détail de l'identité (pas réussi à le faire avec le sélecteur css) + "targets": [1, 2, 3], // ".identite_detail", + "visible": false, + }, + ], + dom: 'Bfrtip', + buttons: [ + 'copy', 'excel', 'pdf', + { + extend: 'collection', + text: 'Réglages affichage', + autoClose: true, + buttons: [ + { + name: "toggle_ident", + text: "Civ/Nom/Prénom", + action: function (e, dt, node, config) { + let visible = dt.columns(".identite_detail").visible()[0]; + dt.columns(".identite_detail").visible(!visible); + dt.columns(".identite_court").visible(visible); + dt.buttons('toggle_ident:name').text(visible ? "Civ/Nom/Prénom" : "Nom"); + } + }, + { + name: "toggle_res", + text: "Cacher les ressources", + action: function (e, dt, node, config) { + let visible = dt.columns(".col_res").visible()[0]; + dt.columns(".col_res").visible(!visible); + dt.buttons('toggle_res:name').text(visible ? "Montrer les ressources" : "Cacher les ressources"); + } + }, + { + name: "toggle_sae", + text: "Cacher les SAÉs", + action: function (e, dt, node, config) { + let visible = dt.columns(".col_sae").visible()[0]; + dt.columns(".col_sae").visible(!visible); + dt.buttons('toggle_sae:name').text(visible ? "Montrer les SAÉs" : "Cacher les SAÉs"); + } + }, + ] + } + ] + } + ); + + }); +}); diff --git a/sco_version.py b/sco_version.py index 3609665625..5543f4a4fc 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.1.86" +SCOVERSION = "9.2-86" SCONAME = "ScoDoc"