From 4db6ee368ae00c48635a0ed7157c300b5f0bdb5a Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
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            | 214 +++++++++++++---------
 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, 378 insertions(+), 411 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;
 }}
 </style>
 """
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(
-            """<h2>Confirmer la suppression de l'archive du %s ?</h2>
-               <p>La suppression sera définitive.</p>"""
-            % PVArchive.get_archive_date(archive_id).strftime("%d/%m/%Y %H:%M"),
+            f"""<h2>Confirmer la suppression de l'archive du {
+                PVArchive.get_archive_date(archive_id).strftime("%d/%m/%Y %H:%M")
+            } ?</h2>
+               <p>La suppression sera définitive.</p>
+            """,
             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'<ul><li><a href="{desturl}">Continuer</a></li></ul>')
+        if not dest_url:
+            dest_url = url_tableau
+        H.append(f'<ul><li><a href="{dest_url}">Continuer</a></li></ul>')
 
         return "\n".join(H + footer)
 
@@ -342,8 +342,8 @@ def formsemestre_validation_etud_form(
         <input type="hidden" name="formsemestre_id" value="%s"/>"""
             % (etudid, formsemestre_id)
         )
-        if desturl:
-            H.append('<input type="hidden" name="desturl" value="%s"/>' % desturl)
+        if dest_url:
+            H.append('<input type="hidden" name="desturl" value="%s"/>' % dest_url)
         if sortcol:
             H.append('<input type="hidden" name="sortcol" value="%s"/>' % 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)
+    filename = scu.sanitize_filename(
+        f"""recap-{formsemestre.titre_num()}-{time.strftime("%Y-%m-%d")}"""
+    )
+    if is_file:
+        return _formsemestre_recapcomplet_to_file(
+            formsemestre,
+            tabformat=tabformat,
+            filename=filename,
+            xml_with_decisions=xml_with_decisions,
+            force_publishing=force_publishing,
+        )
 
-    data = _do_formsemestre_recapcomplet(
-        formsemestre_id,
-        format=tabformat,
+    table_html, table = _formsemestre_recapcomplet_to_html(
+        formsemestre,
+        filename=filename,
         mode_jury=mode_jury,
-        xml_with_decisions=xml_with_decisions,
-        force_publishing=force_publishing,
+        tabformat=tabformat,
         selected_etudid=selected_etudid,
     )
-    if is_file:
-        return data
+
     H = [
         html_sco_header.sco_header(
             page_title=f"{formsemestre.sem_modalite()}: "
@@ -131,64 +139,90 @@ def formsemestre_recapcomplet(
         H.append(
             '<select name="tabformat" onchange="document.f.submit()" class="noprint">'
         )
-        for (format, label) in (
+        for (fmt, label) in (
             ("html", "Tableau"),
             ("evals", "Avec toutes les évaluations"),
             ("xlsx", "Excel (non formaté)"),
             ("xlsall", "Excel avec évaluations"),
-            ("xml", "Bulletins XML (obsolète)"),
             ("json", "Bulletins JSON"),
         ):
-            if format == tabformat:
+            if fmt == tabformat:
                 selected = " selected"
             else:
                 selected = ""
-            H.append(f'<option value="{format}"{selected}>{label}</option>')
-        H.append("</select>")
-
+            H.append(f'<option value="{fmt}"{selected}>{label}</option>')
         H.append(
-            f"""&nbsp;(cliquer sur un nom pour afficher son bulletin ou <a class="stdlink"
+            f"""
+            </select>&nbsp;(cliquer sur un nom pour afficher son bulletin ou
+            <a class="stdlink"
             href="{url_for('notes.formsemestre_bulletins_pdf',
-            scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
+                scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
             }">ici avoir le classeur papier</a>)
+            </form>
             """
         )
-    H.append(data)
+
+    H.append(table_html)  # La table
 
     if len(formsemestre.inscriptions) > 0:
-        H.append("</form>")
+        H.append("""<div class="links_under_recap"><ul>""")
         H.append(
-            f"""<p><a class="stdlink" href="{url_for('notes.formsemestre_pvjury',
-            scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
-            }">Voir les décisions du jury</a></p>"""
+            f"""<li><a class="stdlink" href="{url_for('notes.formsemestre_recapcomplet',
+                    scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, mode_jury=1)
+            }">Décisions du jury</a>
+            </li>
+            """
         )
-        if sco_permissions_check.can_validate_sem(formsemestre_id):
-            H.append("<p>")
+        if formsemestre.can_edit_jury():
             if mode_jury:
                 H.append(
-                    f"""<p><a class="stdlink" href="{url_for('notes.formsemestre_validation_auto',
+                    f"""<li><a class="stdlink" href="{url_for('notes.formsemestre_validation_auto',
                     scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
                     }">Calcul automatique des décisions du jury</a>
-                    </p><a class="stdlink" href="{url_for('notes.formsemestre_jury_but_erase',
+                    </li>
+                    <li><a class="stdlink" href="{url_for('notes.formsemestre_jury_but_erase',
                     scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, only_one_sem=1)
-                    }">Effacer <em>toutes</em> les décisions de jury du semestre</a>
-                    <p>
-                    </p>
+                    }">Effacer <em>toutes</em> les décisions de jury (BUT) du semestre</a>
+                    </li>
                     """
                 )
-            else:
-                H.append(
-                    f"""<a class="stdlink" href="{url_for('notes.formsemestre_recapcomplet',
-                    scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, mode_jury=1)
-                    }">Saisie des décisions du jury</a>"""
-                )
-            H.append("</p>")
+        H.append("</ul></div>")
+
         if sco_preferences.get_preference("use_ue_coefs", formsemestre_id):
             H.append(
                 """
             <p class="infop">utilise les coefficients d'UE pour calculer la moyenne générale.</p>
             """
             )
+
+        if mode_jury and table and sum(table.freq_codes_annuels.values()) > 0:
+            H.append(
+                f"""
+                <div class="jury_stats">
+                    <div>Nb d'étudiants avec décision annuelle:
+                        {sum(table.freq_codes_annuels.values())} / {len(table)}
+                    </div>
+                    <div><b>Codes annuels octroyés:</b></div>
+                    <table class="jury_stats_codes">
+                """
+            )
+            for code in sorted(table.freq_codes_annuels.keys()):
+                H.append(
+                    f"""<tr>
+                    <td>{code}</td>
+                    <td style="text-align:right">{table.freq_codes_annuels[code]}</td>
+                    <td style="text-align:right">{
+                        (100*table.freq_codes_annuels[code] / len(table)):2.1f}%
+                    </td>
+                    </tr>"""
+                )
+            H.append(
+                """
+                </table>
+            </div>
+            """
+            )
+
     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, occultera colonnes modules (en js)
+    et affiche un lien vers la saisie de la décision de jury
 
-    Si mode_jury, cache colonnes modules et affiche un lien vers la saisie de la décision de jury
+    Return: html (str), table (None sauf en mode jury ou si pas cachée)
 
-    Return: data, filename
-    data est une chaine, le <div>...</div> incluant le tableau.
+    html est une chaine, le <div>...</div> 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"""
-        <div class="table_recap">
-        {
-            '<div class="message">aucun étudiant !</div>'
-            if table.is_empty()
-            else table.html(
-                extra_classes=[
-                    'table_recap',
-                    'apc' if formsemestre.formation.is_apc() else 'classic',
-                    'jury' if mode_jury else ''
-                ])
-        }
-        </div>
-        """
+    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: '<a title="Rétablir l\'affichage par défaut">&#10135;</a>',
+                text: '<a title="Rétablir l\'affichage par défaut" class="clearreload">&#128260;</a>',
                 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: '<span data-group="recorded_code">Code jurys</span>',
+                    text: '<span data-group="recorded_code">Codes jury</span>',
                     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: '<span data-group="eval">Évaluations</span>',
+                    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"""{("&#10152; 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 (
-            '<div class="table_recap"><div class="message">aucun étudiant !</div></div>'
-        )
-    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"""<h3>Décisions de jury enregistrées pour les étudiants de ce semestre</h3>
-            <div class="table_jury_but_links">
-                <div>
-                    <ul>
-                    <li><a href="{url_for(
-                        "notes.pvjury_table_but",
-                        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
-                    }" class="stdlink">Tableau PV de jury</a>
-                    </li>
-                    <li><a href="{url_for(
-                        "notes.formsemestre_lettres_individuelles",
-                        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
-                    }" class="stdlink">Courriers individuels (classeur pdf)</a>
-                    </li>
-                </div>
-            </div>
-            """
-        )
-    H.append(
-        f"""
-    <div class="table_recap">
-    {table_html}
-    </div>
-
-    <div class="table_jury_but_links">
-    """
-    )
-
-    if (mode == "recap") and not read_only:
-        H.append(
-            f"""
-    <p><a class="stdlink" href="{url_for(
-        "notes.formsemestre_saisie_jury",
-        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
-    }">Saisie des décisions du jury</a>
-    </p>"""
-        )
-    else:
-        H.append(
-            f"""
-    <p><a class="stdlink" href="{url_for(
-        "notes.formsemestre_validation_auto_but",
-        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
-    }">Calcul automatique des décisions du jury</a>
-    </p>
-    <p><a class="stdlink" href="{url_for(
-        "notes.formsemestre_jury_but_recap",
-        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
-    }">Tableau récapitulatif des décisions du jury</a>
-    </p>
-    """
-        )
-    H.append(
-        f"""
-    </div>
-
-    <div class="jury_stats">
-        <div>Nb d'étudiants avec décision annuelle:
-            {sum(table.freq_codes_annuels.values())} / {len(table)}
-        </div>
-        <div><b>Codes annuels octroyés:</b></div>
-        <table class="jury_stats_codes">
-    """
-    )
-    for code in sorted(table.freq_codes_annuels.keys()):
-        H.append(
-            f"""<tr>
-            <td>{code}</td>
-            <td style="text-align:right">{table.freq_codes_annuels[code]}</td>
-            <td style="text-align:right">{
-                (100*table.freq_codes_annuels[code] / len(table)):2.1f}%
-            </td>
-            </tr>"""
-        )
-    H.append(
-        f"""
-        </table>
-    </div>
-    {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"""
+        <div class="table_recap">
+        {
+            '<div class="message">aucun étudiant !</div>'
+            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 "",
+                ])
+        }
+        </div>
+        """
+
 
 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="<p>Opération non autorisée pour %s</h2>" % 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="<p>Opération non autorisée pour %s</h2>" % 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}
         </div>
         <div class="back_list">
-            <a href="{url_for(
-                "notes.formsemestre_saisie_jury", scodoc_dept=g.scodoc_dept,
-                formsemestre_id=formsemestre_id, selected_etudid=etud.id
+            <a href="{
+                url_for(
+                    "notes.formsemestre_recapcomplet",
+                    scodoc_dept=g.scodoc_dept,
+                    formsemestre_id=formsemestre_id,
+                    mode_jury=1,
+                    selected_etudid=etud.id
             )}" class="stdlink">retour à la liste</a>
         </div>
         <div class="next">
@@ -2583,15 +2595,16 @@ def formsemestre_validation_but(
 @permission_required(Permission.ScoView)
 def formsemestre_validation_auto_but(formsemestre_id: int = None):
     "Saisie automatique des décisions de jury BUT"
-    if not sco_permissions_check.can_validate_sem(formsemestre_id):
-        return scu.confirm_dialog(
-            message=f"<p>Opération non autorisée pour {current_user}</h2>",
+    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,
-            ),
+            )
         )
+
     formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
     form = jury_but_forms.FormSemestreValidationAutoBUTForm()
     if request.method == "POST":
@@ -2602,9 +2615,10 @@ def formsemestre_validation_auto_but(formsemestre_id: int = None):
             flash(f"Décisions enregistrées ({nb_etud_modif} étudiants modifiés)")
         return redirect(
             url_for(
-                "notes.formsemestre_saisie_jury",
+                "notes.formsemestre_recapcomplet",
                 scodoc_dept=g.scodoc_dept,
                 formsemestre_id=formsemestre_id,
+                mode_jury=1,
             )
         )
     return render_template(
@@ -2621,11 +2635,16 @@ def formsemestre_validation_auto_but(formsemestre_id: int = None):
 @scodoc7func
 def formsemestre_validate_previous_ue(formsemestre_id, etudid=None):
     "Form. saisie UE validée hors ScoDoc"
-    if not sco_permissions_check.can_validate_sem(formsemestre_id):
-        return scu.confirm_dialog(
-            message="<p>Opération non autorisée pour %s</h2>" % 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_validate_previous_ue(
         formsemestre_id, etudid
     )
@@ -2645,11 +2664,16 @@ sco_publish(
 @scodoc7func
 def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid=None):
     "Form. edition UE semestre extérieur"
-    if not sco_permissions_check.can_validate_sem(formsemestre_id):
-        return scu.confirm_dialog(
-            message="<p>Opération non autorisée pour %s</h2>" % 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_exterieurs.formsemestre_ext_edit_ue_validations(
         formsemestre_id, etudid
     )
@@ -2668,11 +2692,16 @@ sco_publish(
 @scodoc7func
 def etud_ue_suppress_validation(etudid, formsemestre_id, ue_id):
     """Suppress a validation (ue_id, etudid) and redirect to formsemestre"""
-    if not sco_permissions_check.can_validate_sem(formsemestre_id):
-        return scu.confirm_dialog(
-            message="<p>Opération non autorisée pour %s</h2>" % 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.etud_ue_suppress_validation(
         etudid, formsemestre_id, ue_id
     )
@@ -2684,14 +2713,18 @@ def etud_ue_suppress_validation(etudid, formsemestre_id, ue_id):
 @scodoc7func
 def formsemestre_validation_auto(formsemestre_id):
     "Formulaire saisie automatisee des decisions d'un semestre"
-    if not sco_permissions_check.can_validate_sem(formsemestre_id):
-        return scu.confirm_dialog(
-            message="<p>Opération non autorisée pour %s</h2>" % current_user,
-            dest_url=scu.ScoURL(),
-        )
     formsemestre: FormSemestre = FormSemestre.query.filter_by(
         id=formsemestre_id, dept_id=g.scodoc_dept_id
     ).first_or_404()
+    if not formsemestre.can_edit_jury():
+        raise ScoPermissionDenied(
+            dest_url=url_for(
+                "notes.formsemestre_status",
+                scodoc_dept=g.scodoc_dept,
+                formsemestre_id=formsemestre_id,
+            )
+        )
+
     if formsemestre.formation.is_apc():
         return redirect(
             url_for(
@@ -2709,10 +2742,14 @@ def formsemestre_validation_auto(formsemestre_id):
 @scodoc7func
 def do_formsemestre_validation_auto(formsemestre_id):
     "Formulaire saisie automatisee des decisions d'un semestre"
-    if not sco_permissions_check.can_validate_sem(formsemestre_id):
-        return scu.confirm_dialog(
-            message="<p>Opération non autorisée pour %s</h2>" % 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.do_formsemestre_validation_auto(formsemestre_id)
@@ -2726,13 +2763,16 @@ def formsemestre_validation_suppress_etud(
     formsemestre_id, etudid, dialog_confirmed=False
 ):
     """Suppression des décisions de jury pour un étudiant."""
-    if not sco_permissions_check.can_validate_sem(formsemestre_id):
-        return scu.confirm_dialog(
-            message="<p>Opération non autorisée pour %s</h2>" % 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,
+            )
         )
     etud = Identite.query.get_or_404(etudid)
-    formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
     if formsemestre.formation.is_apc():
         next_url = url_for(
             "scolar.ficheEtud",
@@ -2800,15 +2840,8 @@ sco_publish("/pvjury_table_but", jury_but_pv.pvjury_table_but, Permission.ScoVie
 @scodoc7func
 def formsemestre_saisie_jury(formsemestre_id: int, selected_etudid: int = None):
     """Page de saisie: liste des étudiants et lien vers page jury
-    en semestres pairs de BUT, table spécifique avec l'année
     sinon, redirect vers page recap en mode jury
     """
-    read_only = not sco_permissions_check.can_validate_sem(formsemestre_id)
-    formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
-    if formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0:
-        return jury_recap.formsemestre_saisie_jury_but(
-            formsemestre, read_only, selected_etudid=selected_etudid
-        )
     return redirect(
         url_for(
             "notes.formsemestre_recapcomplet",
@@ -2819,23 +2852,6 @@ def formsemestre_saisie_jury(formsemestre_id: int, selected_etudid: int = None):
     )
 
 
-@bp.route("/formsemestre_jury_but_recap")
-@scodoc
-@permission_required(Permission.ScoView)
-@scodoc7func
-def formsemestre_jury_but_recap(formsemestre_id: int, selected_etudid: int = None):
-    """Tableau affichage des codes"""
-    read_only = not sco_permissions_check.can_validate_sem(formsemestre_id)
-    formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
-    if not (formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0):
-        raise ScoValueError(
-            "formsemestre_jury_but_recap: réservé aux semestres pairs de BUT"
-        )
-    return jury_recap.formsemestre_saisie_jury_but(
-        formsemestre, read_only=read_only, selected_etudid=selected_etudid, mode="recap"
-    )
-
-
 @bp.route(
     "/formsemestre_jury_but_erase/<int:formsemestre_id>",
     methods=["GET", "POST"],
@@ -2855,18 +2871,25 @@ def formsemestre_jury_but_erase(
     Si l'étudiant n'est pas spécifié, efface les décisions de tous les inscrits.
     """
     only_one_sem = int(request.args.get("only_one_sem") or False)
-    if not sco_permissions_check.can_validate_sem(formsemestre_id):
-        raise ScoValueError("opération non autorisée")
-    formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
+    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,
+            )
+        )
     if not formsemestre.formation.is_apc():
         raise ScoValueError("semestre non BUT")
     if etudid is None:
         etud = None
         etuds = formsemestre.get_inscrits(include_demdef=True)
         dest_url = url_for(
-            "notes.formsemestre_saisie_jury",
+            "notes.formsemestre_recapcomplet",
             scodoc_dept=g.scodoc_dept,
             formsemestre_id=formsemestre_id,
+            mode_jury=1,
         )
     else:
         etud: Identite = Identite.query.get_or_404(etudid)
-- 
GitLab