From 4db6ee368ae00c48635a0ed7157c300b5f0bdb5a Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 12 Feb 2023 01:13:43 +0100 Subject: [PATCH] Refactoring et uniformisation tables jury/recap. --- app/models/formsemestre.py | 26 ++- app/scodoc/html_sco_header.py | 2 +- app/scodoc/sco_archives.py | 59 ++++-- app/scodoc/sco_bulletins.py | 6 +- app/scodoc/sco_exceptions.py | 15 +- app/scodoc/sco_formsemestre_status.py | 7 +- app/scodoc/sco_formsemestre_validation.py | 18 +- app/scodoc/sco_permissions.py | 4 - app/scodoc/sco_permissions_check.py | 25 +-- app/scodoc/sco_preferences.py | 2 +- app/scodoc/sco_pvjury.py | 18 +- app/scodoc/sco_recapcomplet.py | 218 +++++++++++++--------- app/static/css/gt_table.css | 9 +- app/static/css/scodoc.css | 11 +- app/static/js/table_recap.js | 18 +- app/tables/jury_recap.py | 148 +-------------- app/tables/recap.py | 32 +++- app/views/notes.py | 175 +++++++++-------- 18 files changed, 380 insertions(+), 413 deletions(-) diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index f58a38b72..57e5b92fb 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -13,6 +13,7 @@ import datetime from functools import cached_property +from flask_login import current_user import flask_sqlalchemy from flask import flash, g from sqlalchemy import and_, or_ @@ -20,6 +21,7 @@ from sqlalchemy.sql import text import app.scodoc.sco_utils as scu from app import db, log +from app.auth.models import User from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN from app.models.but_refcomp import ( ApcAnneeParcours, @@ -535,10 +537,32 @@ class FormSemestre(db.Model): else: return ", ".join([u.get_nomcomplet() for u in self.responsables]) - def est_responsable(self, user): + def est_responsable(self, user: User): "True si l'user est l'un des responsables du semestre" return user.id in [u.id for u in self.responsables] + def est_chef_or_diretud(self, user: User = None): + "Vrai si utilisateur (par def. current) est admin, chef dept ou responsable du semestre" + user = user or current_user + return user.has_permission(Permission.ScoImplement) or self.est_responsable( + user + ) + + def can_edit_jury(self, user: User = None): + """Vrai si utilisateur (par def. current) peut saisir decision de jury + dans ce semestre: vérifie permission et verrouillage. + """ + user = user or current_user + return self.etat and self.est_chef_or_diretud(user) + + def can_edit_pv(self, user: User = None): + "Vrai si utilisateur (par def. current) peut editer un PV de jury de ce semestre" + user = user or current_user + # Autorise les secrétariats, repérés via la permission ScoEtudChangeAdr + return self.est_chef_or_diretud(user) or user.has_permission( + Permission.ScoEtudChangeAdr + ) + def annee_scolaire(self) -> int: """L'année de début de l'année scolaire. Par exemple, 2022 si le semestre va de septembre 2022 à février 2023.""" diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index e4d330a78..e31162e3d 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -251,7 +251,7 @@ def sco_header( #gtrcontent {{ margin-left: {params["margin_left"]}; height: 100%%; - margin-bottom: 10px; + margin-bottom: 16px; }} """ diff --git a/app/scodoc/sco_archives.py b/app/scodoc/sco_archives.py index 5d0955543..2fe04513d 100644 --- a/app/scodoc/sco_archives.py +++ b/app/scodoc/sco_archives.py @@ -47,7 +47,7 @@ qui est une description (humaine, format libre) de l'archive. """ -import chardet +from typing import Union import datetime import glob import json @@ -56,10 +56,11 @@ import os import re import shutil import time -from typing import Union + +import chardet import flask -from flask import g, request +from flask import flash, g, request, url_for from flask_login import current_user import app.scodoc.sco_utils as scu @@ -70,9 +71,7 @@ from app.comp import res_sem from app.comp.res_compat import NotesTableCompat from app.models import Departement, FormSemestre from app.scodoc.TrivialFormulator import TrivialFormulator -from app.scodoc.sco_exceptions import ( - AccessDenied, -) +from app.scodoc.sco_exceptions import AccessDenied, ScoPermissionDenied from app.scodoc import html_sco_header from app.scodoc import sco_bulletins_pdf from app.scodoc import sco_formsemestre @@ -314,7 +313,7 @@ def do_formsemestre_archive( """ from app.scodoc.sco_recapcomplet import ( gen_formsemestre_recapcomplet_excel, - gen_formsemestre_recapcomplet_html, + gen_formsemestre_recapcomplet_html_table, gen_formsemestre_recapcomplet_json, ) @@ -338,7 +337,7 @@ def do_formsemestre_archive( if data: PVArchive.store(archive_id, "Tableau_moyennes" + scu.XLSX_SUFFIX, data) # Tableau recap notes en HTML (pour tous les etudiants, n'utilise pas les groupes) - table_html = gen_formsemestre_recapcomplet_html( + table_html, _ = gen_formsemestre_recapcomplet_html_table( formsemestre, res, include_evaluations=True ) if table_html: @@ -416,8 +415,15 @@ def formsemestre_archive(formsemestre_id, group_ids=[]): """Make and store new archive for this formsemestre. (all students or only selected groups) """ - if not sco_permissions_check.can_edit_pv(formsemestre_id): - raise AccessDenied("opération non autorisée pour %s" % str(current_user)) + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + if not formsemestre.can_edit_pv(): + raise ScoPermissionDenied( + dest_url=url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) sem = sco_formsemestre.get_formsemestre(formsemestre_id) if not group_ids: @@ -579,26 +585,38 @@ def formsemestre_list_archives(formsemestre_id): def formsemestre_get_archived_file(formsemestre_id, archive_name, filename): """Send file to client.""" - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - sem_archive_id = formsemestre_id + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + sem_archive_id = formsemestre.id return PVArchive.get_archived_file(sem_archive_id, archive_name, filename) def formsemestre_delete_archive(formsemestre_id, archive_name, dialog_confirmed=False): """Delete an archive""" - if not sco_permissions_check.can_edit_pv(formsemestre_id): - raise AccessDenied("opération non autorisée pour %s" % str(current_user)) - sem = sco_formsemestre.get_formsemestre(formsemestre_id) + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + if not formsemestre.can_edit_pv(): + raise ScoPermissionDenied( + dest_url=url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) sem_archive_id = formsemestre_id archive_id = PVArchive.get_id_from_name(sem_archive_id, archive_name) - dest_url = "formsemestre_list_archives?formsemestre_id=%s" % (formsemestre_id) + dest_url = url_for( + "notes.formsemestre_list_archives", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) if not dialog_confirmed: return scu.confirm_dialog( - """

Confirmer la suppression de l'archive du %s ?

-

La suppression sera définitive.

""" - % PVArchive.get_archive_date(archive_id).strftime("%d/%m/%Y %H:%M"), + f"""

Confirmer la suppression de l'archive du { + PVArchive.get_archive_date(archive_id).strftime("%d/%m/%Y %H:%M") + } ?

+

La suppression sera définitive.

+ """, dest_url="", cancel_url=dest_url, parameters={ @@ -608,4 +626,5 @@ def formsemestre_delete_archive(formsemestre_id, archive_name, dialog_confirmed= ) PVArchive.delete_archive(archive_id) - return flask.redirect(dest_url + "&head_message=Archive%20supprimée") + flash("Archive supprimée") + return flask.redirect(dest_url) diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py index 392a20bd2..4be4c11d1 100644 --- a/app/scodoc/sco_bulletins.py +++ b/app/scodoc/sco_bulletins.py @@ -1208,7 +1208,7 @@ def make_menu_autres_operations( "formsemestre_id": formsemestre.id, "etudid": etud.id, }, - "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), + "enabled": formsemestre.can_edit_jury(), }, { "title": "Enregistrer note d'une UE externe", @@ -1217,7 +1217,7 @@ def make_menu_autres_operations( "formsemestre_id": formsemestre.id, "etudid": etud.id, }, - "enabled": sco_permissions_check.can_validate_sem(formsemestre.id) + "enabled": formsemestre.can_edit_jury() and not formsemestre.formation.is_apc(), }, { @@ -1227,7 +1227,7 @@ def make_menu_autres_operations( "formsemestre_id": formsemestre.id, "etudid": etud.id, }, - "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), + "enabled": formsemestre.can_edit_jury(), }, { "title": "Éditer PV jury", diff --git a/app/scodoc/sco_exceptions.py b/app/scodoc/sco_exceptions.py index 501964708..77c9a0779 100644 --- a/app/scodoc/sco_exceptions.py +++ b/app/scodoc/sco_exceptions.py @@ -27,22 +27,21 @@ """Exception handling """ +from flask_login import current_user # --- Exceptions -MSGPERMDENIED = "l'utilisateur %s n'a pas le droit d'effectuer cette operation" - - class ScoException(Exception): - pass + "super classe de toutes les exceptions ScoDoc." class InvalidNoteValue(ScoException): - pass + "Valeur note invalide. Usage interne saisie note." class ScoValueError(ScoException): "Exception avec page d'erreur utilisateur, et qui stoque dest_url" - + # mal nommée: super classe de toutes les exceptions avec page + # d'erreur gentille. def __init__(self, msg, dest_url=None): super().__init__(msg) self.dest_url = dest_url @@ -53,7 +52,9 @@ class ScoPermissionDenied(ScoValueError): def __init__(self, msg=None, dest_url=None): if msg is None: - msg = "Opération non autorisée !" + msg = f"""Opération non autorisée pour { + current_user.get_nomcomplet() if current_user else "?" + }. Pas la permission, ou objet verrouillé.""" super().__init__(msg, dest_url=dest_url) diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 0e699868c..5a5f8d5af 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -431,17 +431,18 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str: }, { "title": "Saisie des décisions du jury", - "endpoint": "notes.formsemestre_saisie_jury", + "endpoint": "notes.formsemestre_recapcomplet", "args": { "formsemestre_id": formsemestre_id, + "mode_jury": 1, }, - "enabled": sco_permissions_check.can_validate_sem(formsemestre_id), + "enabled": formsemestre.can_edit_jury(), }, { "title": "Éditer les PV et archiver les résultats", "endpoint": "notes.formsemestre_archive", "args": {"formsemestre_id": formsemestre_id}, - "enabled": sco_permissions_check.can_edit_pv(formsemestre_id), + "enabled": formsemestre.can_edit_pv(), }, { "title": "Documents archivés", diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index 3d2a7835a..e84d5bc61 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -72,9 +72,9 @@ def formsemestre_validation_etud_form( etudid=None, # one of etudid or etud_index is required etud_index=None, check=0, # opt: si true, propose juste une relecture du parcours - desturl=None, + dest_url=None, sortcol=None, - readonly=True, + read_only=True, ): """Formulaire de validation des décisions de jury""" formsemestre: FormSemestre = FormSemestre.query.filter_by( @@ -111,7 +111,7 @@ def formsemestre_validation_etud_form( etud_index_prev = etud_index - 1 if etud_index_prev < 0: etud_index_prev = None - if readonly: + if read_only: check = True etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] @@ -216,13 +216,13 @@ def formsemestre_validation_etud_form( H.append( formsemestre_recap_parcours_table( - Se, etudid, with_links=(check and not readonly) + Se, etudid, with_links=(check and not read_only) ) ) if check: - if not desturl: - desturl = url_tableau - H.append(f'') + if not dest_url: + dest_url = url_tableau + H.append(f'') return "\n".join(H + footer) @@ -342,8 +342,8 @@ def formsemestre_validation_etud_form( """ % (etudid, formsemestre_id) ) - if desturl: - H.append('' % desturl) + if dest_url: + H.append('' % dest_url) if sortcol: H.append('' % sortcol) diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py index a17b74faa..78c6c10c6 100644 --- a/app/scodoc/sco_permissions.py +++ b/app/scodoc/sco_permissions.py @@ -55,10 +55,6 @@ _SCO_PERMISSIONS = ( ), # 27 à 39 ... réservé pour "entreprises" (1 << 40, "ScoEtudChangePhoto", "Modifier la photo d'un étudiant"), - # Api scodoc9 - # XXX à revoir - # (1 << 42, "APIEditAllNotes", "API: Modifier toutes les notes"), - # (1 << 43, "APIAbsChange", "API: Saisir des absences"), ) diff --git a/app/scodoc/sco_permissions_check.py b/app/scodoc/sco_permissions_check.py index c0a72952b..224881bf8 100644 --- a/app/scodoc/sco_permissions_check.py +++ b/app/scodoc/sco_permissions_check.py @@ -101,30 +101,7 @@ def can_edit_suivi(): return current_user.has_permission(Permission.ScoEtudChangeAdr) -def can_validate_sem(formsemestre_id): - "Vrai si utilisateur peut saisir decision de jury dans ce semestre" - from app.scodoc import sco_formsemestre - - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - if not sem["etat"]: - return False # semestre verrouillé - - return is_chef_or_diretud(sem) - - -def can_edit_pv(formsemestre_id): - "Vrai si utilisateur peut editer un PV de jury de ce semestre" - from app.scodoc import sco_formsemestre - - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - if is_chef_or_diretud(sem): - return True - # Autorise les secrétariats, repérés via la permission ScoEtudChangeAdr - # (ceci nous évite d'ajouter une permission Zope aux installations existantes) - return current_user.has_permission(Permission.ScoEtudChangeAdr) - - -def is_chef_or_diretud(sem): +def is_chef_or_diretud(sem): # remplacé par formsemestre.est_chef_or_diretud "Vrai si utilisateur est admin, chef dept ou responsable du semestre" if ( current_user.has_permission(Permission.ScoImplement) diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index e0c8c0a35..5c86d9ffc 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -1243,7 +1243,7 @@ class BasePreferences(object): { "initvalue": 0, "title": "Afficher toutes les évaluations sur les bulletins", - "explanation": "y compris incomplètes ou futures (déconseillé, risque de publier des notes non définitives)", + "explanation": "y compris incomplètes ou futures (déconseillé, risque de publier des notes non définitives; n'affecte pas le calcul des moyennes)", "input_type": "boolcheckbox", "category": "bul", "labels": ["non", "oui"], diff --git a/app/scodoc/sco_pvjury.py b/app/scodoc/sco_pvjury.py index 416c985ba..b8a85c625 100644 --- a/app/scodoc/sco_pvjury.py +++ b/app/scodoc/sco_pvjury.py @@ -516,18 +516,18 @@ def pvjury_table( def formsemestre_pvjury(formsemestre_id, format="html", publish=True): """Page récapitulant les décisions de jury""" - - # Bretelle provisoire pour BUT 9.3.0 - # XXX TODO formsemestre = FormSemestre.query.get_or_404(formsemestre_id) is_apc = formsemestre.formation.is_apc() - if format == "html" and is_apc and formsemestre.semestre_id % 2 == 0: - from app.tables import jury_recap - - return jury_recap.formsemestre_saisie_jury_but( - formsemestre, read_only=True, mode="recap" + if format == "html" and is_apc: + return redirect( + url_for( + "notes.formsemestre_recapcomplet", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + mode_jury=1, + ) ) - # /XXX + footer = html_sco_header.sco_footer() dpv = dict_pvjury(formsemestre_id, with_prev=True) diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index 602289db4..85ca00421 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -51,7 +51,6 @@ from app.scodoc import sco_evaluations from app.scodoc.sco_exceptions import ScoValueError from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre_status -from app.scodoc import sco_permissions_check from app.scodoc import sco_preferences from app.tables.recap import TableRecap from app.tables.jury_recap import TableJury @@ -95,17 +94,26 @@ def formsemestre_recapcomplet( mode_jury = int(mode_jury) xml_with_decisions = int(xml_with_decisions) force_publishing = int(force_publishing) - - data = _do_formsemestre_recapcomplet( - formsemestre_id, - format=tabformat, - mode_jury=mode_jury, - xml_with_decisions=xml_with_decisions, - force_publishing=force_publishing, - selected_etudid=selected_etudid, + filename = scu.sanitize_filename( + f"""recap-{formsemestre.titre_num()}-{time.strftime("%Y-%m-%d")}""" ) if is_file: - return data + return _formsemestre_recapcomplet_to_file( + formsemestre, + tabformat=tabformat, + filename=filename, + xml_with_decisions=xml_with_decisions, + force_publishing=force_publishing, + ) + + table_html, table = _formsemestre_recapcomplet_to_html( + formsemestre, + filename=filename, + mode_jury=mode_jury, + tabformat=tabformat, + selected_etudid=selected_etudid, + ) + H = [ html_sco_header.sco_header( page_title=f"{formsemestre.sem_modalite()}: " @@ -131,64 +139,90 @@ def formsemestre_recapcomplet( H.append( '") - + H.append(f'') H.append( - f""" (cliquer sur un nom pour afficher son bulletin ou  (cliquer sur un nom pour afficher son bulletin ou + ici avoir le classeur papier) + """ ) - H.append(data) + + H.append(table_html) # La table if len(formsemestre.inscriptions) > 0: - H.append("") + H.append("""") + if sco_preferences.get_preference("use_ue_coefs", formsemestre_id): H.append( """

utilise les coefficients d'UE pour calculer la moyenne générale.

""" ) + + if mode_jury and table and sum(table.freq_codes_annuels.values()) > 0: + H.append( + f""" +
+
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""" + + + + """ + ) + H.append( + """ +
{code}{table.freq_codes_annuels[code]}{ + (100*table.freq_codes_annuels[code] / len(table)):2.1f}% +
+
+ """ + ) + H.append(html_sco_header.sco_footer()) # HTML or binary data ? if len(H) > 1: @@ -199,62 +233,69 @@ def formsemestre_recapcomplet( return H -def _do_formsemestre_recapcomplet( - formsemestre_id=None, - format="html", # html, xml, xls, xlsall, json - xml_nodate=False, # format XML sans dates (sert pour debug cache: comparaison de XML) +def _formsemestre_recapcomplet_to_html( + formsemestre: FormSemestre, + tabformat="html", # "html" or "evals" + filename: str = "", mode_jury=False, # saisie décisions jury + selected_etudid=None, +) -> tuple[str, TableRecap]: + """Le tableau recap en html""" + if tabformat not in ("html", "evals"): + raise ScoValueError("invalid table format") + res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + table_html, table = gen_formsemestre_recapcomplet_html_table( + formsemestre, + res, + include_evaluations=(tabformat == "evals"), + mode_jury=mode_jury, + filename=filename, + selected_etudid=selected_etudid, + ) + return table_html, table + + +def _formsemestre_recapcomplet_to_file( + formsemestre: FormSemestre, + tabformat: str = "json", # xml, xls, xlsall, json + filename: str = "", + xml_nodate=False, # format XML sans dates (sert pour debug cache: comparaison de XML) xml_with_decisions=False, force_publishing=True, - selected_etudid=None, ): """Calcule et renvoie le tableau récapitulatif.""" - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - - filename = scu.sanitize_filename( - f"""recap-{formsemestre.titre_num()}-{time.strftime("%Y-%m-%d")}""" - ) - - if format == "html" or format == "evals": + if tabformat.startswith("xls"): res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - data = gen_formsemestre_recapcomplet_html( - formsemestre, - res, - include_evaluations=(format == "evals"), - mode_jury=mode_jury, - filename=filename, - selected_etudid=selected_etudid, - ) - return data - elif format.startswith("xls"): - res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - include_evaluations = format in {"xlsall", "csv "} # csv not supported anymore - if format != "csv": - format = "xlsx" + include_evaluations = tabformat in { + "xlsall", + "csv ", + } # csv not supported anymore + if tabformat != "csv": + tabformat = "xlsx" data, filename = gen_formsemestre_recapcomplet_excel( res, include_evaluations=include_evaluations, filename=filename, ) return scu.send_file(data, filename=filename, mime=scu.get_mime_suffix(format)) - elif format == "xml": + elif tabformat == "xml": data = gen_formsemestre_recapcomplet_xml( - formsemestre_id, + formsemestre.id, xml_nodate, xml_with_decisions=xml_with_decisions, force_publishing=force_publishing, ) return scu.send_file(data, filename=filename, suffix=scu.XML_SUFFIX) - elif format == "json": + elif tabformat == "json": data = gen_formsemestre_recapcomplet_json( - formsemestre_id, + formsemestre.id, xml_nodate=xml_nodate, xml_with_decisions=xml_with_decisions, force_publishing=force_publishing, ) return scu.sendJSON(data, filename=filename) - raise ScoValueError(f"Format demandé invalide: {format}") + raise ScoValueError(f"Format demandé invalide: {tabformat}") def gen_formsemestre_recapcomplet_xml( @@ -368,22 +409,26 @@ def formsemestres_bulletins(annee_scolaire): return scu.sendJSON(js_list) -def gen_formsemestre_recapcomplet_html( +def gen_formsemestre_recapcomplet_html_table( formsemestre: FormSemestre, res: NotesTableCompat, include_evaluations=False, mode_jury=False, filename="", selected_etudid=None, -): +) -> tuple[str, TableRecap]: """Construit table recap pour le BUT Cache le résultat pour le semestre (sauf en mode jury). + Note: on cache le HTML et non l'objet Table. - Si mode_jury, cache colonnes modules et affiche un lien vers la saisie de la décision de jury + Si mode_jury, occultera colonnes modules (en js) + et affiche un lien vers la saisie de la décision de jury - Return: data, filename - data est une chaine, le
...
incluant le tableau. + Return: html (str), table (None sauf en mode jury ou si pas cachée) + + html est une chaine, le
...
incluant le tableau. """ + table = None table_html = None if not (mode_jury or selected_etudid): if include_evaluations: @@ -392,7 +437,7 @@ def gen_formsemestre_recapcomplet_html( table_html = sco_cache.TableRecapCache.get(formsemestre.id) # en mode jury ne cache pas la table html if mode_jury or (table_html is None): - table_html = _gen_formsemestre_recapcomplet_html( + table = _gen_formsemestre_recapcomplet_table( formsemestre, res, include_evaluations, @@ -400,48 +445,37 @@ def gen_formsemestre_recapcomplet_html( filename, selected_etudid=selected_etudid, ) + table_html = table.html() if not mode_jury: if include_evaluations: sco_cache.TableRecapWithEvalsCache.set(formsemestre.id, table_html) else: sco_cache.TableRecapCache.set(formsemestre.id, table_html) - return table_html + return table_html, table -def _gen_formsemestre_recapcomplet_html( +def _gen_formsemestre_recapcomplet_table( formsemestre: FormSemestre, res: ResultatsSemestre, include_evaluations=False, mode_jury=False, filename: str = "", selected_etudid=None, -) -> str: - """Génère le html""" +) -> TableRecap: + """Construit la table récap.""" table_class = TableJury if mode_jury else TableRecap table = table_class( res, convert_values=True, include_evaluations=include_evaluations, mode_jury=mode_jury, + read_only=not formsemestre.can_edit_jury(), ) table.data["filename"] = filename table.select_row(selected_etudid) - return f""" -
- { - '
aucun étudiant !
' - if table.is_empty() - else table.html( - extra_classes=[ - 'table_recap', - 'apc' if formsemestre.formation.is_apc() else 'classic', - 'jury' if mode_jury else '' - ]) - } -
- """ + return table def gen_formsemestre_recapcomplet_excel( diff --git a/app/static/css/gt_table.css b/app/static/css/gt_table.css index d35ca909f..90c9bf08c 100644 --- a/app/static/css/gt_table.css +++ b/app/static/css/gt_table.css @@ -1,6 +1,7 @@ /* * DataTables style for ScoDoc gen_tables * generated using https://datatables.net/manual/styling/theme-creator + * and customized by hand */ /* @@ -374,9 +375,11 @@ table.dataTable td { float: left; } -.dataTables_wrapper .dataTables_filter { - float: right; - text-align: right; +.dataTables_wrapper div.dataTables_filter { + float: left; + text-align: left; + margin-left: 64px; + margin-top: 4px; } .dataTables_wrapper .dataTables_filter input { diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 84b3e1708..b9edff50f 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -35,7 +35,7 @@ h3 { } div#gtrcontent { - margin-bottom: 4ex; + margin-bottom: 16px; } .gtrcontent { @@ -4015,8 +4015,14 @@ div.table_recap { background: linear-gradient(to bottom, rgb(51, 255, 0) 0%, lightgray 100%); } +/* Non supproté par les navigateurs (en Fev. 2023) +.table_recap button:has(span a.clearreaload) { +} +*/ + div.table_recap table.table_recap { width: auto; + margin-left: 0px; /* font-family: Consolas, monaco, monospace; */ } @@ -4344,6 +4350,9 @@ div.table_jury_but_links { margin-bottom: 16px; } +div.links_under_recap ul li { + padding-bottom: 8px; +} /* ------------- Tableau stats jury BUT -------- */ table.jury_stats_codes { diff --git a/app/static/js/table_recap.js b/app/static/js/table_recap.js index bc3c1ef4c..e1ec6ee28 100644 --- a/app/static/js/table_recap.js +++ b/app/static/js/table_recap.js @@ -8,11 +8,6 @@ $(function () { "partition_aux", "partition_rangs", "admission", "col_empty" ]; - let mode_jury_but_bilan = $('table.table_recap').hasClass("table_jury_but_bilan"); - if (mode_jury_but_bilan) { - // table bilan décisions: cache les notes - hidden_colums = hidden_colums.concat(["col_lien_saisie_but"]); - } // Etat (tri des colonnes) de la table: const url = new URL(document.URL); @@ -99,7 +94,7 @@ $(function () { } }, { - text: '', + text: '🔄', action: function (e, dt, node, config) { localStorage.clear(); console.log("cleared localStorage"); @@ -124,7 +119,7 @@ $(function () { // table jury: avec ou sans codes enregistrés buttons.push( { - text: 'Code jurys', + text: 'Codes jury', action: toggle_col_but_visibility, }); } else { @@ -165,6 +160,15 @@ $(function () { ); } } + // Boutons évaluations (si présentes) + if ($('table.table_recap').hasClass("with_evaluations")) { + buttons.push( + { + text: 'Évaluations', + action: toggle_col_but_visibility, + } + ); + } // ------------- LA TABLE --------- try { diff --git a/app/tables/jury_recap.py b/app/tables/jury_recap.py index db8497d3c..13df372d8 100644 --- a/app/tables/jury_recap.py +++ b/app/tables/jury_recap.py @@ -114,7 +114,7 @@ class TableJury(TableRecap): "jury_link", "", f"""{("➨ saisir" if a_saisir else "modifier") - if res.formsemestre.etat else "voir"} décisions""", + if not self.read_only else "voir"} décisions""", group="col_jury_link", classes=["fontred"] if a_saisir else [], target=url_for( @@ -250,149 +250,3 @@ class RowJury(RowRecap): # f"""{int(ects_valides)}""", # "col_code_annee", # ) - - -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 ( - '
aucun étudiant !
' - ) - 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""" - - - - """ - ) - H.append( - f""" -
{code}{table.freq_codes_annuels[code]}{ - (100*table.freq_codes_annuels[code] / len(table)):2.1f}% -
-
- {html_sco_header.sco_footer()} - """ - ) - return "\n".join(H) diff --git a/app/tables/recap.py b/app/tables/recap.py index a7788d82e..2ca38363f 100644 --- a/app/tables/recap.py +++ b/app/tables/recap.py @@ -54,6 +54,7 @@ class TableRecap(tb.Table): mode_jury=False, row_class=None, finalize=True, + read_only: bool = True, **kwargs, ): self.rows: list["RowRecap"] = [] # juste pour que VSCode nous aide sur .rows @@ -61,7 +62,7 @@ class TableRecap(tb.Table): self.res = res self.include_evaluations = include_evaluations self.mode_jury = mode_jury - + self.read_only = read_only # utilisé seulement dans sous-classes parcours = res.formsemestre.formation.get_parcours() self.barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE self.barre_valid_ue = parcours.NOTES_BARRE_VALID_UE @@ -103,7 +104,7 @@ class TableRecap(tb.Table): self.add_cursus() self.add_admissions() - # tri par rang croissant + # Tri par rang croissant if not res.formsemestre.block_moyenne_generale: self.sort_rows(key=lambda row: row.rang_order) else: @@ -361,6 +362,7 @@ class TableRecap(tb.Table): pour tous les étudiants de la table. Les colonnes ont la classe css "evaluation" """ + self.group_titles["eval"] = "Évaluations" # nouvelle ligne pour description évaluations: row_descr_eval = tb.BottomRow( self, @@ -382,7 +384,7 @@ class TableRecap(tb.Table): for e in evals: col_id = f"eval_{e.id}" title = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}' - col_classes = ["evaluation"] + col_classes = [] if first_eval: col_classes.append("first") elif first_eval_of_mod: @@ -408,13 +410,15 @@ class TableRecap(tb.Table): "EXC": "exc", }.get(content, "") ] - row.add_cell(col_id, title, content, "", classes=classes) + row.add_cell( + col_id, title, content, group="eval", classes=classes + ) else: row.add_cell( col_id, title, "ni", - "", + group="eval", classes=col_classes + ["non_inscrit"], ) @@ -505,6 +509,24 @@ class TableRecap(tb.Table): group="cursus", ) + def html(self, extra_classes: list[str] = None) -> str: + """HTML: pour les tables recap, un div au contenu variable""" + return f""" +
+ { + '
aucun étudiant !
' + if self.is_empty() + else super().html( + extra_classes=[ + "table_recap", + "apc" if self.res.formsemestre.formation.is_apc() else "classic", + "jury" if self.mode_jury else "", + "with_evaluations" if self.include_evaluations else "", + ]) + } +
+ """ + class RowRecap(tb.Row): "Ligne de la table recap, pour un étudiant" diff --git a/app/views/notes.py b/app/views/notes.py index c32d0cca8..185c29734 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -59,7 +59,7 @@ from app.models.formsemestre import FormSemestreUEComputationExpr from app.models.moduleimpls import ModuleImpl from app.models.modules import Module from app.models.ues import DispenseUE, UniteEns -from app.scodoc.sco_exceptions import ScoFormationConflict +from app.scodoc.sco_exceptions import ScoFormationConflict, ScoPermissionDenied from app.tables import jury_recap from app.views import notes_bp as bp @@ -2257,8 +2257,8 @@ def formsemestre_validation_etud_form( sortcol=None, ): "Formulaire choix jury pour un étudiant" - readonly = not sco_permissions_check.can_validate_sem(formsemestre_id) - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + read_only = not formsemestre.can_edit_jury() if formsemestre.formation.is_apc(): return redirect( url_for( @@ -2273,8 +2273,8 @@ def formsemestre_validation_etud_form( etudid=etudid, etud_index=etud_index, check=check, - readonly=readonly, - desturl=desturl, + read_only=read_only, + dest_url=desturl, sortcol=sortcol, ) @@ -2291,10 +2291,14 @@ def formsemestre_validation_etud( sortcol=None, ): "Enregistre choix jury pour un étudiant" - if not sco_permissions_check.can_validate_sem(formsemestre_id): - return scu.confirm_dialog( - message="

Opération non autorisée pour %s" % current_user, - dest_url=scu.ScoURL(), + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + if not formsemestre.can_edit_jury(): + raise ScoPermissionDenied( + dest_url=url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) ) return sco_formsemestre_validation.formsemestre_validation_etud( @@ -2321,10 +2325,14 @@ def formsemestre_validation_etud_manu( sortcol=None, ): "Enregistre choix jury pour un étudiant" - if not sco_permissions_check.can_validate_sem(formsemestre_id): - return scu.confirm_dialog( - message="

Opération non autorisée pour %s" % current_user, - dest_url=scu.ScoURL(), + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + if not formsemestre.can_edit_jury(): + raise ScoPermissionDenied( + dest_url=url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) ) return sco_formsemestre_validation.formsemestre_validation_etud_manu( @@ -2364,7 +2372,7 @@ def formsemestre_validation_but( etudid = int(etudid) except ValueError: abort(404, "invalid etudid") - read_only = not sco_permissions_check.can_validate_sem(formsemestre_id) + read_only = not formsemestre.can_edit_jury() # --- Navigation prev_lnk = ( @@ -2391,9 +2399,13 @@ def formsemestre_validation_but( {prev_lnk}

- retour à la liste