From cc1d9c03c31ee255b71d6586e0406ad2ba20b703 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 14 Sep 2024 16:42:12 +0200 Subject: [PATCH] Tableau bilan UEs. Implements #989 --- app/models/ues.py | 3 +- app/scodoc/sco_formsemestre_status.py | 5 + app/static/css/scodoc.css | 5 +- app/tables/bilan_ues.py | 196 +++++++++++++++++++ app/tables/list_etuds.py | 47 +++-- app/templates/jury/formsemestre_bilan_ues.j2 | 64 ++++++ app/views/jury_validations.py | 33 +++- sco_version.py | 2 +- 8 files changed, 332 insertions(+), 23 deletions(-) create mode 100644 app/tables/bilan_ues.py create mode 100644 app/templates/jury/formsemestre_bilan_ues.j2 diff --git a/app/models/ues.py b/app/models/ues.py index e5aa8445..5c458620 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -201,7 +201,7 @@ class UniteEns(models.ScoDocModel): m.modimpls.all() for m in self.modules ) - def guess_semestre_idx(self) -> None: + def guess_semestre_idx(self) -> int: """Lorsqu'on prend une ancienne formation non APC, les UE n'ont pas d'indication de semestre. Cette méthode fixe le semestre en prenant celui du premier module, @@ -215,6 +215,7 @@ class UniteEns(models.ScoDocModel): self.semestre_idx = module.semestre_id db.session.add(self) db.session.commit() + return self.semestre_idx def get_ects(self, parcour: ApcParcours = None, only_parcours=False) -> float: """Crédits ECTS associés à cette UE. diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index c530bd17..93c382db 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -456,6 +456,11 @@ def formsemestre_status_menubar(formsemestre: FormSemestre | None) -> str: formsemestre_id ), }, + { + "title": "Bilan des UEs (expérimental)", + "endpoint": "notes.formsemestre_bilan_ues", + "args": {"formsemestre_id": formsemestre_id}, + }, ] menu_stats = _build_menu_stats(formsemestre) diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index d27fa8a9..56e76e29 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -4511,7 +4511,7 @@ table.table_recap td.cursus_BUT1 { } table.table_recap td.cursus_BUT2 { - color: #d39f00; + color: #24848e; } table.table_recap td.cursus_BUT3 { @@ -4632,7 +4632,8 @@ table.table_recap tr.selected td.moy_ue_valid { } table.table_recap td.moy_ue_warning, -table.table_recap tr.selected td.moy_ue_warning { +table.table_recap tr.selected td.moy_ue_warning, +td.moy_ue_warning { color: rgb(255, 0, 0); } diff --git a/app/tables/bilan_ues.py b/app/tables/bilan_ues.py new file mode 100644 index 00000000..ac73c8d6 --- /dev/null +++ b/app/tables/bilan_ues.py @@ -0,0 +1,196 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Table bilan UEs des étudiants d'un formsemestre +Voir https://git.scodoc.org/ScoDoc/ScoDoc/issues/989 +""" +from functools import partial +from collections import defaultdict + +from app.models import ( + Formation, + FormSemestre, + Identite, + ScolarFormSemestreValidation, + UniteEns, +) +from app.scodoc import sco_formsemestre_validation +from app.scodoc.codes_cursus import BUT_CODES_ORDER +from app.scodoc import sco_utils as scu +from app.tables.list_etuds import TableEtud, RowEtudWithInfos + + +def _acronym_ues_same_key(ues: set[UniteEns]) -> str: + """Les UEs de même code pouvant (rarement, espérons) avoir des acronymes différents, + les regroupe dans une chaîne. + """ + # Note: on peut former un ensemble d'UE, voir + # https://stackoverflow.com/questions/8179068/sqlalchemy-id-equality-vs-reference-equality + acronymes = sorted({ue.acronyme for ue in ues}) + return acronymes[0] + ( + " (" + ", ".join(acronymes[1:]) + ")" if len(acronymes) > 1 else "" + ) + + +def _best_validation( + validations: list[ScolarFormSemestreValidation], +) -> ScolarFormSemestreValidation | None: + """Retourne le code jury de la meilleure validation + (au sens du code jury puis de la note moyenne) + """ + if not validations: + return None + best_validation = max( + validations, key=lambda v: (BUT_CODES_ORDER.get(v.code, -1), v.moy_ue or 0.0) + ) + return best_validation + + +def get_etud_ue_validations_by_ue_key( + etud: Identite, formation: Formation +) -> dict[str | tuple[int, int], list[ScolarFormSemestreValidation]]: + """Retourne les validations d'UEs pour un étudiant dans une formation. + Considère toutes les formations de même code que celle indiquée, puis regroupe les validations + par clé d'UE: + - en formations classiques, `ue.ue_code` + - en APC tuple `(ue.semestre_idx, ue.niveau_id)` + """ + validations = sco_formsemestre_validation.get_etud_ue_validations(etud, formation) + etud_ue_validations_by_ue_key = defaultdict(list) + if formation.is_apc(): + for v in validations: + etud_ue_validations_by_ue_key[ + (v.ue.semestre_idx, v.ue.niveau_competence_id) + ].append(v) + else: + for v in validations: + etud_ue_validations_by_ue_key[v.ue.ue_code].append(v) + return etud_ue_validations_by_ue_key + + +class TableBilanUEs(TableEtud): + """Table listant des étudiants avec bilan UEs""" + + def __init__( + self, + formsemestre: FormSemestre, + convert_values=True, + classes: list[str] = None, + row_class=None, + with_foot_titles=False, + **kwargs, + ): + self.formsemestre = formsemestre + etuds = formsemestre.etuds + if convert_values: + self.fmt_note = scu.fmt_note + else: + self.fmt_note = partial(scu.fmt_note, keep_numeric=True) + cursus = formsemestre.formation.get_cursus() + self.barre_moy = cursus.BARRE_MOY - scu.NOTES_TOLERANCE + self.is_apc = formsemestre.formation.is_apc() + self.validations_by_ue_key_by_etudid: dict[ + int, dict[str | tuple[int, int] : list[ScolarFormSemestreValidation]] + ] = {} + "validations d'UEs par étudiant, indicées par clé d'UE (code ou sem/niv. de comp.)" + self.ues_by_key: defaultdict[str | tuple[int, int] : set[UniteEns]] = ( + defaultdict(set) + ) + "ensemble des UEs associées à chaque clé" + for etud in etuds: + self.validations_by_ue_key_by_etudid[etud.id] = ( + get_etud_ue_validations_by_ue_key(etud, formsemestre.formation) + ) + # Le titre de chaque clé d'UE + for key, validations in self.validations_by_ue_key_by_etudid[ + etud.id + ].items(): + self.ues_by_key[key].update({v.ue for v in validations}) + # Titres des UEs + self.ue_titles = { + key: _acronym_ues_same_key(ues) for key, ues in self.ues_by_key.items() + } + # prend une UE pour chaque clé pour déterminer l'ordre des UEs + ue_by_key = {key: next(iter(ues)) for key, ues in self.ues_by_key.items()} + self.sorted_ues_keys = sorted( + self.ue_titles.keys(), + key=lambda k: ( + ue_by_key[k].guess_semestre_idx(), + ue_by_key[k].numero, + ), + ) + "clés d'UEs triées par semestre/numero" + super().__init__( + etuds=etuds, + classes=classes or ["gt_table", "gt_left", "with-highlight", "table_recap"], + row_class=row_class or RowEtudWithUEs, + with_foot_titles=with_foot_titles, + **kwargs, + ) + + def add_etuds(self, etuds: list[Identite]): + "Ajoute des étudiants à la table" + for etud in etuds: + row = self.row_class( + self, + etud, + self.formsemestre, + self.formsemestre.etuds_inscriptions.get(etud.id), + ) + row.add_etud_cols() + self.add_row(row) + + +class RowEtudWithUEs(RowEtudWithInfos): + """Ligne de la table d'étudiants avec colonnes UEs""" + + with_boursier = False # inutile dans cette table + with_departement = False + with_formsemestre = False + with_ine = True + + def add_etud_cols(self): + """Ajoute colonnes pour cet étudiant""" + super().add_etud_cols() + # Ajoute les colonnes d'UEs + for key in self.table.sorted_ues_keys: + self.add_etud_ue_cols(key) + + def add_etud_ue_cols(self, key): + "colonnes pour une clé d'UE pour l'étudiant" + validations = self.table.validations_by_ue_key_by_etudid[self.etud.id].get( + key, [] + ) + if not validations: + return + best_validation = _best_validation(validations) + moy_ue = best_validation.moy_ue + note_class = "" + if moy_ue is None: + moy_ue = scu.NO_NOTE_STR + if isinstance(moy_ue, float) and moy_ue < self.table.barre_moy: + note_class = "moy_ue_warning" # en rouge + self.add_cell( + str(key) + "_note", + self.table.ue_titles[key], + self.table.fmt_note(moy_ue), + data={"order": moy_ue}, + raw_content=moy_ue, + classes=[note_class], + column_classes={"note_ue"}, + group=f"col_s{best_validation.ue.semestre_idx}", + ) + if self.table.is_apc: + class_cursus = f"cursus_BUT{(best_validation.ue.semestre_idx+1)//2}" + else: + class_cursus = "" + self.add_cell( + str(key) + "_code", + self.table.ue_titles[key], + best_validation.code, + column_classes={"code_ue", class_cursus}, + group=f"col_s{best_validation.ue.semestre_idx}", + ) diff --git a/app/tables/list_etuds.py b/app/tables/list_etuds.py index 463295d9..714cf29f 100644 --- a/app/tables/list_etuds.py +++ b/app/tables/list_etuds.py @@ -6,7 +6,6 @@ """Liste simple d'étudiants """ -import datetime from app.models import FormSemestre, FormSemestreInscription, Identite from app.scodoc.sco_exceptions import ScoValueError @@ -131,6 +130,11 @@ class RowEtudWithInfos(RowEtud): département, formsemestre, codes, boursier """ + with_boursier = True + with_departement = True + with_formsemestre = True + with_ine = False + def __init__( self, table: TableEtud, @@ -146,17 +150,25 @@ class RowEtudWithInfos(RowEtud): def add_etud_cols(self): """Ajoute colonnes étudiant: codes, noms""" - self.add_cell("dept", "Dépt.", self.etud.departement.acronym, "identite_detail") - self.add_cell( - "formsemestre", - "Semestre", - f"""{self.formsemestre.titre_formation()} { - ('S'+str(self.formsemestre.semestre_id)) - if self.formsemestre.semestre_id > 0 else ''} - """, - "identite_detail", - ) + if self.with_departement: + self.add_cell( + "dept", "Dépt.", self.etud.departement.acronym, "identite_detail" + ) + if self.with_formsemestre: + self.add_cell( + "formsemestre", + "Semestre", + f"""{self.formsemestre.titre_formation()} { + ('S'+str(self.formsemestre.semestre_id)) + if self.formsemestre.semestre_id > 0 else ''} + """, + "identite_detail", + ) self.add_cell("code_nip", "NIP", self.etud.code_nip or "", "identite_detail") + if self.with_ine: + self.add_cell( + "code_ine", "INE", self.etud.code_ine or "", "identite_detail" + ) super().add_etud_cols() self.add_cell( "etat", @@ -164,12 +176,13 @@ class RowEtudWithInfos(RowEtud): self.inscription.etat, "inscription", ) - self.add_cell( - "boursier", - "Boursier", - "O" if self.etud.boursier else "N", - "identite_infos", - ) + if self.with_boursier: + self.add_cell( + "boursier", + "Boursier", + "O" if self.etud.boursier else "N", + "identite_infos", + ) class TableEtudWithInfos(TableEtud): diff --git a/app/templates/jury/formsemestre_bilan_ues.j2 b/app/templates/jury/formsemestre_bilan_ues.j2 new file mode 100644 index 00000000..fe846dd6 --- /dev/null +++ b/app/templates/jury/formsemestre_bilan_ues.j2 @@ -0,0 +1,64 @@ +{# Table bilan résultats d'UEs #} + +{% extends "sco_page.j2" %} + +{% block styles %} + {{ super() }} + + +{% endblock styles %} + +{% block app_content %} +
+ +

Bilan des UEs validées dans leur cursus par les étudiants du semestre

+ +
page expérimentale, à utiliser avec précaution, merci de commenter sur le + {{scu.SCO_DISCORD_ASSISTANCE}}. +
+ +
+ {{table.html()|safe}} +
+ {{scu.ICON_XLS|safe}} + +
+{% endblock %} + + +{% block scripts %} + {{ super() }} + +{% endblock %} diff --git a/app/views/jury_validations.py b/app/views/jury_validations.py index b7527e78..e7678a28 100644 --- a/app/views/jury_validations.py +++ b/app/views/jury_validations.py @@ -74,8 +74,7 @@ from app.scodoc.sco_exceptions import ( ) from app.scodoc.sco_permissions import Permission from app.scodoc.sco_pv_dict import descr_autorisations - -# from app.scodoc.TrivialFormulator import TrivialFormulator +from app.tables import bilan_ues from app.views import notes_bp as bp from app.views import ScoData @@ -1032,3 +1031,33 @@ def etud_bilan_ects(etudid: int): validations_by_diplome=validations_by_diplome, sco=ScoData(etud=etud), ) + + +@bp.route("/bilan_ues/") +@scodoc +@permission_required(Permission.ScoView) +def formsemestre_bilan_ues(formsemestre_id: int): + """Table bilan validations UEs pour les étudiants du semestre""" + fmt = request.args.get("fmt", "html") + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + + table = bilan_ues.TableBilanUEs(formsemestre) + if fmt.startswith("xls"): + return scu.send_file( + table.excel(), + scu.make_filename( + f"""{formsemestre.titre_num()}-bilan-ues-{ + datetime.datetime.now().strftime("%Y-%m-%dT%Hh%M")}""" + ), + scu.XLSX_SUFFIX, + mime=scu.XLSX_MIMETYPE, + ) + if fmt == "html": + return render_template( + "jury/formsemestre_bilan_ues.j2", + sco=ScoData(formsemestre=formsemestre), + table=table, + title=f"Bilan UEs {formsemestre.titre_num()}", + ) + else: + raise ScoValueError("invalid fmt value") diff --git a/sco_version.py b/sco_version.py index e9fb9384..32ed764b 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.7.21" +SCOVERSION = "9.7.22" SCONAME = "ScoDoc"