From 998820e671b19f03e80a1c3a4480d983914a8d88 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Sun, 19 Feb 2023 15:45:27 +0100
Subject: [PATCH] =?UTF-8?q?R=C3=A9organisation=20du=20code=20de=20g=C3=A9n?=
 =?UTF-8?q?ration=20de=20PV=20de=20jury=20PDF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/but/jury_but_pv.py                        |   2 +-
 app/but/jury_but_results.py                   |   4 +-
 app/scodoc/sco_archives.py                    |  72 +-
 app/scodoc/sco_bulletins.py                   |   4 +-
 app/scodoc/sco_export_results.py              |   6 +-
 app/scodoc/sco_formsemestre_validation.py     |   4 +-
 app/scodoc/sco_inscr_passage.py               |   4 +-
 app/scodoc/sco_pdf.py                         |   7 +-
 .../{sco_dict_pv_jury.py => sco_pv_dict.py}   |   0
 app/scodoc/{sco_pvjury.py => sco_pv_forms.py} |  37 +-
 app/scodoc/sco_pv_lettres_inviduelles.py      | 357 +++++++
 app/scodoc/sco_pv_pdf.py                      | 340 +++++++
 app/scodoc/sco_pv_templates.py                | 344 +++++++
 app/scodoc/sco_pvpdf.py                       | 942 ------------------
 app/scodoc/sco_report.py                      |   1 -
 app/templates/about.j2                        |  13 +
 app/views/notes.py                            |  11 +-
 sco_version.py                                |  11 +-
 tests/unit/yaml_setup_but.py                  |   4 +-
 19 files changed, 1133 insertions(+), 1030 deletions(-)
 rename app/scodoc/{sco_dict_pv_jury.py => sco_pv_dict.py} (100%)
 rename app/scodoc/{sco_pvjury.py => sco_pv_forms.py} (95%)
 create mode 100644 app/scodoc/sco_pv_lettres_inviduelles.py
 create mode 100644 app/scodoc/sco_pv_pdf.py
 create mode 100644 app/scodoc/sco_pv_templates.py
 delete mode 100644 app/scodoc/sco_pvpdf.py

diff --git a/app/but/jury_but_pv.py b/app/but/jury_but_pv.py
index b3ffba779..32dd5a0f7 100644
--- a/app/but/jury_but_pv.py
+++ b/app/but/jury_but_pv.py
@@ -97,7 +97,7 @@ def pvjury_table_but(
     """Table avec résultats jury BUT pour PV.
     Si etudids est None, prend tous les étudiants inscrits.
     """
-    # remplace pour le BUT la fonction sco_pvjury.pvjury_table
+    # remplace pour le BUT la fonction sco_pv_forms.pvjury_table
     annee_but = (formsemestre.semestre_id + 1) // 2
     titles = {
         "nom": "Nom",
diff --git a/app/but/jury_but_results.py b/app/but/jury_but_results.py
index d947bf845..f6a208f3e 100644
--- a/app/but/jury_but_results.py
+++ b/app/but/jury_but_results.py
@@ -12,7 +12,7 @@ import numpy as np
 from app.but import jury_but
 from app.models.etudiants import Identite
 from app.models.formsemestre import FormSemestre
-from app.scodoc import sco_dict_pv_jury
+from app.scodoc import sco_pv_dict
 
 
 def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]:
@@ -20,7 +20,7 @@ def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]:
     if formsemestre.formation.referentiel_competence is None:
         # pas de ref. comp., donc pas de decisions de jury (ne lance pas d'exception)
         return []
-    dpv = sco_dict_pv_jury.dict_pvjury(formsemestre.id)
+    dpv = sco_pv_dict.dict_pvjury(formsemestre.id)
     rows = []
     for etudid in formsemestre.etuds_inscriptions:
         rows.append(_get_jury_but_etud_result(formsemestre, dpv, etudid))
diff --git a/app/scodoc/sco_archives.py b/app/scodoc/sco_archives.py
index 3e3ff43aa..839780fe3 100644
--- a/app/scodoc/sco_archives.py
+++ b/app/scodoc/sco_archives.py
@@ -43,8 +43,8 @@
  Les maquettes Apogée pour l'export des notes sont dans
     <archivedir>/apo_csv/<dept_id>/<annee_scolaire>-<sem_id>/<YYYY-MM-DD-HH-MM-SS>/<code_etape>.csv
 
- Un répertoire d'archive contient des fichiers quelconques, et un fichier texte nommé _description.txt
- qui est une description (humaine, format libre) de l'archive.
+ Un répertoire d'archive contient des fichiers quelconques, et un fichier texte
+ nommé _description.txt qui est une description (humaine, format libre) de l'archive.
 
 """
 from typing import Union
@@ -61,7 +61,6 @@ import chardet
 
 import flask
 from flask import flash, g, request, url_for
-from flask_login import current_user
 
 import app.scodoc.sco_utils as scu
 from config import Config
@@ -74,12 +73,11 @@ from app.scodoc.TrivialFormulator import TrivialFormulator
 from app.scodoc.sco_exceptions import ScoPermissionDenied
 from app.scodoc import html_sco_header
 from app.scodoc import sco_bulletins_pdf
-from app.scodoc import sco_formsemestre
 from app.scodoc import sco_groups
 from app.scodoc import sco_groups_view
-from app.scodoc import sco_pvjury
-from app.scodoc import sco_dict_pv_jury
-from app.scodoc import sco_pvpdf
+from app.scodoc import sco_pv_forms
+from app.scodoc import sco_pv_lettres_inviduelles
+from app.scodoc import sco_pv_pdf
 from app.scodoc.sco_exceptions import ScoValueError
 
 
@@ -210,7 +208,7 @@ class BaseArchiver(object):
         self.initialize()
         filename = os.path.join(archive_id, "_description.txt")
         try:
-            with open(filename) as f:
+            with open(filename, encoding=scu.SCO_ENCODING) as f:
                 descr = f.read()
         except UnicodeDecodeError:
             # some (old) files may have saved under exotic encodings
@@ -294,7 +292,7 @@ PVArchive = SemsArchiver()
 
 def do_formsemestre_archive(
     formsemestre_id,
-    group_ids=[],  # si indiqué, ne prend que ces groupes
+    group_ids: list[int] = None,  # si indiqué, ne prend que ces groupes
     description="",
     date_jury="",
     signature=None,  # pour lettres indiv
@@ -349,7 +347,8 @@ def do_formsemestre_archive(
                     no_side_bar=True,
                 ),
                 f'<h2 class="fontorange">Valeurs archivées le {date}</h2>',
-                '<style type="text/css">table.notes_recapcomplet tr {  color: rgb(185,70,0); }</style>',
+                """<style type="text/css">table.notes_recapcomplet tr {  color: rgb(185,70,0); }
+                </style>""",
                 table_html,
                 html_sco_header.sco_footer(),
             ]
@@ -366,7 +365,7 @@ def do_formsemestre_archive(
         response = jury_but_pv.pvjury_page_but(formsemestre_id, fmt="xls")
         data = response.get_data()
     else:  # formations classiques
-        data = sco_pvjury.formsemestre_pvjury(
+        data = sco_pv_forms.formsemestre_pvjury(
             formsemestre_id, format="xls", publish=False
         )
     if data:
@@ -382,7 +381,7 @@ def do_formsemestre_archive(
     if data:
         PVArchive.store(archive_id, "Bulletins.pdf", data)
     # Lettres individuelles (PDF):
-    data = sco_pvpdf.pdf_lettres_individuelles(
+    data = sco_pv_lettres_inviduelles.pdf_lettres_individuelles(
         formsemestre_id,
         etudids=etudids,
         date_jury=date_jury,
@@ -390,27 +389,23 @@ def do_formsemestre_archive(
         signature=signature,
     )
     if data:
-        PVArchive.store(archive_id, "CourriersDecisions%s.pdf" % groups_filename, data)
+        PVArchive.store(archive_id, f"CourriersDecisions{groups_filename}.pdf", data)
 
-    # PV de jury (PDF): disponible seulement en classique
-    # en BUT, le PV est sous forme excel (Decisions_Jury.xlsx ci-dessus)
-    if not formsemestre.formation.is_apc():
-        dpv = sco_dict_pv_jury.dict_pvjury(
-            formsemestre_id, etudids=etudids, with_prev=True
-        )
-        data = sco_pvpdf.pvjury_pdf(
-            dpv,
-            date_commission=date_commission,
-            date_jury=date_jury,
-            numero_arrete=numero_arrete,
-            code_vdi=code_vdi,
-            show_title=show_title,
-            pv_title=pv_title,
-            with_paragraph_nom=with_paragraph_nom,
-            anonymous=anonymous,
-        )
-        if data:
-            PVArchive.store(archive_id, "PV_Jury%s.pdf" % groups_filename, data)
+    # PV de jury (PDF):
+    data = sco_pv_pdf.pvjury_pdf(
+        formsemestre,
+        etudids=etudids,
+        date_commission=date_commission,
+        date_jury=date_jury,
+        numero_arrete=numero_arrete,
+        code_vdi=code_vdi,
+        show_title=show_title,
+        pv_title=pv_title,
+        with_paragraph_nom=with_paragraph_nom,
+        anonymous=anonymous,
+    )
+    if data:
+        PVArchive.store(archive_id, f"PV_Jury{groups_filename}.pdf", data)
 
 
 def formsemestre_archive(formsemestre_id, group_ids: list[int] = None):
@@ -450,7 +445,11 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
         """,
     ]
     F = [
-        """<p><em>Note: les documents sont aussi affectés par les réglages sur la page "<a href="edit_preferences">Paramétrage</a>" (accessible à l'administrateur du département).</em>
+        f"""<p><em>Note: les documents sont aussi affectés par les réglages sur la page 
+        "<a class="stdlink" href="{
+            url_for("scolar.edit_preferences", scodoc_dept=g.scodoc_dept)
+        }">Paramétrage</a>"
+        (accessible à l'administrateur du département).</em>
         </p>""",
         html_sco_header.sco_footer(),
     ]
@@ -462,7 +461,7 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
         ),
         ("sep", {"input_type": "separator", "title": "Informations sur PV de jury"}),
     ]
-    descr += sco_pvjury.descrform_pvjury(formsemestre)
+    descr += sco_pv_forms.descrform_pvjury(formsemestre)
     descr += [
         (
             "signature",
@@ -507,7 +506,7 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
     if tf[0] == 0:
         return "\n".join(H) + "\n" + tf[1] + "\n".join(F)
     elif tf[0] == -1:
-        msg = "Opération%20annulée"
+        msg = "Opération annulée"
     else:
         # submit
         sf = tf[2]["signature"]
@@ -531,7 +530,7 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
             anonymous=tf[2]["anonymous"],
             bul_version=tf[2]["bul_version"],
         )
-        msg = "Nouvelle%20archive%20créée"
+        msg = "Nouvelle archive créée"
 
     # submitted or cancelled:
     flash(msg)
@@ -546,7 +545,6 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
 
 def formsemestre_list_archives(formsemestre_id):
     """Page listing archives"""
-    sem = sco_formsemestre.get_formsemestre(formsemestre_id)
     sem_archive_id = formsemestre_id
     L = []
     for archive_id in PVArchive.list_obj_archives(sem_archive_id):
diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py
index 84e810a75..96c85d69a 100644
--- a/app/scodoc/sco_bulletins.py
+++ b/app/scodoc/sco_bulletins.py
@@ -59,7 +59,7 @@ from app.scodoc import sco_evaluation_db
 from app.scodoc import sco_formsemestre
 from app.scodoc import sco_groups
 from app.scodoc import sco_preferences
-from app.scodoc import sco_dict_pv_jury
+from app.scodoc import sco_pv_dict
 from app.scodoc import sco_users
 import app.scodoc.sco_utils as scu
 from app.scodoc.sco_utils import ModuleType, fmt_note
@@ -787,7 +787,7 @@ def etud_descr_situation_semestre(
         infos["date_defaillance"] = date_def
         infos["descr_decision_jury"] = f"Défaillant{ne}"
 
-    dpv = sco_dict_pv_jury.dict_pvjury(formsemestre_id, etudids=[etudid])
+    dpv = sco_pv_dict.dict_pvjury(formsemestre_id, etudids=[etudid])
     if dpv:
         infos["decision_sem"] = dpv["decisions"][0]["decision_sem"]
 
diff --git a/app/scodoc/sco_export_results.py b/app/scodoc/sco_export_results.py
index 4d27ec1aa..d4e9a3784 100644
--- a/app/scodoc/sco_export_results.py
+++ b/app/scodoc/sco_export_results.py
@@ -40,7 +40,7 @@ from app.scodoc import html_sco_header
 from app.scodoc import sco_bac
 from app.scodoc import codes_cursus
 from app.scodoc import sco_preferences
-from app.scodoc import sco_dict_pv_jury
+from app.scodoc import sco_pv_dict
 from app.scodoc import sco_etud
 import sco_version
 from app.scodoc.gen_tables import GenTable
@@ -57,7 +57,7 @@ def _build_results_table(start_date=None, end_date=None, types_parcours=[]):
     # Décisions de jury de tous les semestres:
     dpv_by_sem = {}
     for formsemestre_id in formsemestre_ids:
-        dpv_by_sem[formsemestre_id] = sco_dict_pv_jury.dict_pvjury(
+        dpv_by_sem[formsemestre_id] = sco_pv_dict.dict_pvjury(
             formsemestre_id, with_parcours_decisions=True
         )
 
@@ -348,7 +348,7 @@ end_date='2017-08-31'
 formsemestre_ids = get_set_formsemestre_id_dates( start_date, end_date)
 dpv_by_sem = {}
 for formsemestre_id in formsemestre_ids:
-    dpv_by_sem[formsemestre_id] = sco_dict_pv_jury.dict_pvjury( formsemestre_id, with_parcours_decisions=True)
+    dpv_by_sem[formsemestre_id] = sco_pv_dict.dict_pvjury( formsemestre_id, with_parcours_decisions=True)
 
 semlist = [ dpv['formsemestre'] for dpv in dpv_by_sem.values() ]
 
diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py
index 49ca5de08..c5806fd2e 100644
--- a/app/scodoc/sco_formsemestre_validation.py
+++ b/app/scodoc/sco_formsemestre_validation.py
@@ -64,7 +64,7 @@ from app.scodoc import sco_cursus_dut
 from app.scodoc.sco_cursus_dut import etud_est_inscrit_ue
 from app.scodoc import sco_photos
 from app.scodoc import sco_preferences
-from app.scodoc import sco_dict_pv_jury
+from app.scodoc import sco_pv_dict
 
 # ------------------------------------------------------------------------------------
 def formsemestre_validation_etud_form(
@@ -562,7 +562,7 @@ def formsemestre_recap_parcours_table(
         is_cur = Se.formsemestre_id == sem["formsemestre_id"]
         num_sem += 1
 
-        dpv = sco_dict_pv_jury.dict_pvjury(sem["formsemestre_id"], etudids=[etudid])
+        dpv = sco_pv_dict.dict_pvjury(sem["formsemestre_id"], etudids=[etudid])
         pv = dpv["decisions"][0]
         decision_sem = pv["decision_sem"]
         decisions_ue = pv["decisions_ue"]
diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py
index 197b5169e..1a5953be2 100644
--- a/app/scodoc/sco_inscr_passage.py
+++ b/app/scodoc/sco_inscr_passage.py
@@ -47,7 +47,7 @@ from app.scodoc import sco_formsemestre
 from app.scodoc import sco_formsemestre_inscriptions
 from app.scodoc import sco_groups
 from app.scodoc import sco_preferences
-from app.scodoc import sco_dict_pv_jury
+from app.scodoc import sco_pv_dict
 from app.scodoc.sco_exceptions import ScoValueError
 
 
@@ -137,7 +137,7 @@ def list_inscrits(formsemestre_id, with_dems=False):
 def list_etuds_from_sem(src, dst) -> list[dict]:
     """Liste des etudiants du semestre src qui sont autorisés à passer dans le semestre dst."""
     target = dst["semestre_id"]
-    dpv = sco_dict_pv_jury.dict_pvjury(src["formsemestre_id"])
+    dpv = sco_pv_dict.dict_pvjury(src["formsemestre_id"])
     if not dpv:
         return []
     etuds = [
diff --git a/app/scodoc/sco_pdf.py b/app/scodoc/sco_pdf.py
index 7fca1e679..ffa11d259 100755
--- a/app/scodoc/sco_pdf.py
+++ b/app/scodoc/sco_pdf.py
@@ -221,7 +221,7 @@ class ScoDocPageTemplate(PageTemplate):
     def __init__(
         self,
         document,
-        pagesbookmarks={},
+        pagesbookmarks: dict = None,
         author=None,
         title=None,
         subject=None,
@@ -385,6 +385,11 @@ class BulletinDocTemplate(BaseDocTemplate):
     ajoute la gestion des bookmarks
     """
 
+    def __init__(self, *args, **kw):
+        super().__init__(*args, **kw)
+        self.current_footer = ""
+        self.filigranne = None
+
     # inspired by https://www.reportlab.com/snippets/13/
     def afterFlowable(self, flowable):
         """Called by Reportlab after each flowable"""
diff --git a/app/scodoc/sco_dict_pv_jury.py b/app/scodoc/sco_pv_dict.py
similarity index 100%
rename from app/scodoc/sco_dict_pv_jury.py
rename to app/scodoc/sco_pv_dict.py
diff --git a/app/scodoc/sco_pvjury.py b/app/scodoc/sco_pv_forms.py
similarity index 95%
rename from app/scodoc/sco_pvjury.py
rename to app/scodoc/sco_pv_forms.py
index 6ec676a4a..c5b256e4d 100644
--- a/app/scodoc/sco_pvjury.py
+++ b/app/scodoc/sco_pv_forms.py
@@ -27,23 +27,7 @@
 
 """Edition des PV de jury
 
-PV Jury IUTV 2006: on détaillait 8 cas:
-Jury de semestre n
-    On a 8 types de décisions:
-    Passages:
-    1. passage de ceux qui ont validés Sn-1
-    2. passage avec compensation Sn-1, Sn
-    3. passage sans validation de Sn avec validation d'UE
-    4. passage sans validation de Sn sans validation d'UE
-
-    Redoublements:
-    5. redoublement de Sn-1 et Sn sans validation d'UE pour Sn
-    6. redoublement de Sn-1 et Sn avec validation d'UE pour Sn
-
-    Reports
-    7. report sans validation d'UE
-
-    8. non validation de Sn-1 et Sn et non redoublement
+Formulaires paramétrage PV et génération des tables
 """
 
 import time
@@ -54,25 +38,20 @@ import flask
 from flask import flash, redirect, url_for
 from flask import g, request
 
-from app.models import (
-    Formation,
-    FormSemestre,
-    ScolarAutorisationInscription,
-)
-from app.models.etudiants import Identite
+from app.models import FormSemestre, Identite
 
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
 from app.scodoc import html_sco_header
 from app.scodoc import codes_cursus
-from app.scodoc import sco_dict_pv_jury
+from app.scodoc import sco_pv_dict
 from app.scodoc import sco_etud
-from app.scodoc import sco_formsemestre
 from app.scodoc import sco_groups
 from app.scodoc import sco_groups_view
 from app.scodoc import sco_pdf
 from app.scodoc import sco_preferences
-from app.scodoc import sco_pvpdf
+from app.scodoc import sco_pv_pdf
+from app.scodoc import sco_pv_lettres_inviduelles
 from app.scodoc.gen_tables import GenTable
 from app.scodoc.codes_cursus import NO_SEMESTRE_ID
 from app.scodoc.sco_pdf import PDFLOCK
@@ -245,7 +224,7 @@ def formsemestre_pvjury(formsemestre_id, format="html", publish=True):  # XXX
 
     footer = html_sco_header.sco_footer()
 
-    dpv = sco_dict_pv_jury.dict_pvjury(formsemestre_id, with_prev=True)
+    dpv = sco_pv_dict.dict_pvjury(formsemestre_id, with_prev=True)
     if not dpv:
         if format == "html":
             return (
@@ -427,7 +406,7 @@ def formsemestre_pvjury_pdf(formsemestre_id, group_ids: list[int] = None, etudid
         tf[2]["anonymous"] = bool(tf[2]["anonymous"])
         try:
             PDFLOCK.acquire()
-            pdfdoc = sco_pvpdf.pvjury_pdf(
+            pdfdoc = sco_pv_pdf.pvjury_pdf(
                 formsemestre,
                 etudids,
                 numero_arrete=tf[2]["numero_arrete"],
@@ -596,7 +575,7 @@ def formsemestre_lettres_individuelles(formsemestre_id, group_ids=[]):
         signature = sf.read()  # image of signature
         try:
             PDFLOCK.acquire()
-            pdfdoc = sco_pvpdf.pdf_lettres_individuelles(
+            pdfdoc = sco_pv_lettres_inviduelles.pdf_lettres_individuelles(
                 formsemestre_id,
                 etudids=etudids,
                 date_jury=tf[2]["date_jury"],
diff --git a/app/scodoc/sco_pv_lettres_inviduelles.py b/app/scodoc/sco_pv_lettres_inviduelles.py
new file mode 100644
index 000000000..42f5d4136
--- /dev/null
+++ b/app/scodoc/sco_pv_lettres_inviduelles.py
@@ -0,0 +1,357 @@
+# -*- mode: python -*-
+# -*- coding: utf-8 -*-
+
+##############################################################################
+#
+# Gestion scolarite IUT
+#
+# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+#   Emmanuel Viennet      emmanuel.viennet@viennet.net
+#
+##############################################################################
+
+"""Edition des lettres individuelles de jury
+"""
+# code initialement dans sco_pvpdf.py
+
+import io
+import re
+
+from PIL import Image as PILImage
+from PIL import UnidentifiedImageError
+
+import reportlab
+from reportlab.lib.units import cm, mm
+from reportlab.lib.enums import TA_LEFT
+from reportlab.platypus import PageBreak, Table, Image
+from reportlab.platypus.doctemplate import BaseDocTemplate
+from reportlab.lib import styles
+
+from app.models import FormSemestre, Identite
+
+import app.scodoc.sco_utils as scu
+from app.scodoc import sco_bulletins_pdf
+from app.scodoc import sco_pv_dict
+from app.scodoc import sco_etud
+from app.scodoc import sco_pdf
+from app.scodoc import sco_preferences
+from app.scodoc.sco_exceptions import ScoValueError
+from app.scodoc.sco_cursus_dut import SituationEtudCursus
+from app.scodoc.sco_pv_templates import CourrierIndividuelTemplate, jury_titres
+import sco_version
+
+
+def pdf_lettres_individuelles(
+    formsemestre_id,
+    etudids=None,
+    date_jury="",
+    date_commission="",
+    signature=None,
+):
+    """Document PDF avec les lettres d'avis pour les etudiants mentionnés
+    (tous ceux du semestre, ou la liste indiquée par etudids)
+    Renvoie pdf data ou chaine vide si aucun etudiant avec décision de jury.
+    """
+    dpv = sco_pv_dict.dict_pvjury(formsemestre_id, etudids=etudids, with_prev=True)
+    if not dpv:
+        return ""
+    # Ajoute infos sur etudiants
+    etuds = [x["identite"] for x in dpv["decisions"]]
+    sco_etud.fill_etuds_info(etuds)
+    #
+    formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
+    prefs = sco_preferences.SemPreferences(formsemestre_id)
+    params = {
+        "date_jury": date_jury,
+        "date_commission": date_commission,
+        "titre_formation": dpv["formation"]["titre_officiel"],
+        "htab1": "8cm",  # lignes à droite (entete, signature)
+        "htab2": "1cm",
+    }
+    # copie preferences
+    for name in sco_preferences.get_base_preferences().prefs_name:
+        params[name] = sco_preferences.get_preference(name, formsemestre_id)
+
+    bookmarks = {}
+    objects = []  # list of PLATYPUS objects
+    npages = 0
+    for decision in dpv["decisions"]:
+        if (
+            decision["decision_sem"]
+            or decision.get("decision_annee")
+            or decision.get("decision_rcue")
+        ):  # decision prise
+            etud: Identite = Identite.query.get(decision["identite"]["etudid"])
+            params["nomEtud"] = etud.nomprenom
+            bookmarks[npages + 1] = scu.suppress_accents(etud.nomprenom)
+            try:
+                objects += pdf_lettre_individuelle(
+                    dpv["formsemestre"], decision, etud, params, signature
+                )
+            except UnidentifiedImageError as exc:
+                raise ScoValueError(
+                    "Fichier image (signature ou logo ?) invalide !"
+                ) from exc
+            objects.append(PageBreak())
+            npages += 1
+    if npages == 0:
+        return ""
+    # Paramètres de mise en page
+    margins = (
+        prefs["left_margin"],
+        prefs["top_margin"],
+        prefs["right_margin"],
+        prefs["bottom_margin"],
+    )
+
+    # ----- Build PDF
+    report = io.BytesIO()  # in-memory document, no disk file
+    document = BaseDocTemplate(report)
+    document.addPageTemplates(
+        CourrierIndividuelTemplate(
+            document,
+            author=f"{sco_version.SCONAME} {sco_version.SCOVERSION} (E. Viennet)",
+            title=f"Lettres décision {formsemestre.titre_annee()}",
+            subject="Décision jury",
+            margins=margins,
+            pagesbookmarks=bookmarks,
+            preferences=prefs,
+        )
+    )
+
+    document.build(objects)
+    data = report.getvalue()
+    return data
+
+
+def _simulate_br(paragraph_txt: str, para="<para>") -> str:
+    """Reportlab bug turnaround (could be removed in a future version).
+    p is a string with Reportlab intra-paragraph XML tags.
+    Replaces <br> (currently ignored by Reportlab) by </para><para>
+    Also replaces <br> by <br/>
+    """
+    return ("</para>" + para).join(
+        re.split(r"<.*?br.*?/>", paragraph_txt.replace("<br>", "<br/>"))
+    )
+
+
+def _make_signature_image(signature, leftindent, formsemestre_id) -> Table:
+    "crée un paragraphe avec l'image signature"
+    # cree une image PIL pour avoir la taille (W,H)
+
+    f = io.BytesIO(signature)
+    img = PILImage.open(f)
+    width, height = img.size
+    pdfheight = (
+        1.0
+        * sco_preferences.get_preference("pv_sig_image_height", formsemestre_id)
+        * mm
+    )
+    f.seek(0, 0)
+
+    style = styles.ParagraphStyle({})
+    style.leading = 1.0 * sco_preferences.get_preference(
+        "SCOLAR_FONT_SIZE", formsemestre_id
+    )  # vertical space
+    style.leftIndent = leftindent
+    return Table(
+        [("", Image(f, width=width * pdfheight / float(height), height=pdfheight))],
+        colWidths=(9 * cm, 7 * cm),
+    )
+
+
+def pdf_lettre_individuelle(sem, decision, etud: Identite, params, signature=None):
+    """
+    Renvoie une liste d'objets PLATYPUS pour intégration
+    dans un autre document.
+    """
+    #
+    formsemestre_id = sem["formsemestre_id"]
+    formsemestre = FormSemestre.query.get(formsemestre_id)
+    Se: SituationEtudCursus = decision["Se"]
+    t, s = jury_titres(
+        formsemestre, Se.parcours_validated() or not Se.semestre_non_terminal
+    )
+    objects = []
+    style = reportlab.lib.styles.ParagraphStyle({})
+    style.fontSize = 14
+    style.fontName = sco_preferences.get_preference("PV_FONTNAME", formsemestre_id)
+    style.leading = 18
+    style.alignment = TA_LEFT
+
+    params["semestre_id"] = formsemestre.semestre_id
+    params["decision_sem_descr"] = decision["decision_sem_descr"]
+    params["type_jury"] = t  # type de jury (passage ou delivrance)
+    params["type_jury_abbrv"] = s  # idem, abbrégé
+    params["decisions_ue_descr"] = decision["decisions_ue_descr"]
+    if decision["decisions_ue_nb"] > 1:
+        params["decisions_ue_descr_plural"] = "s"
+    else:
+        params["decisions_ue_descr_plural"] = ""
+
+    params["INSTITUTION_CITY"] = (
+        sco_preferences.get_preference("INSTITUTION_CITY", formsemestre_id) or ""
+    )
+
+    if decision["prev_decision_sem"]:
+        params["prev_semestre_id"] = decision["prev"]["semestre_id"]
+
+    params["prev_decision_sem_txt"] = ""
+    params["decision_orig"] = ""
+
+    params.update(decision["identite"])
+    # fix domicile
+    if params["domicile"]:
+        params["domicile"] = params["domicile"].replace("\\n", "<br/>")
+
+    # UE capitalisées:
+    if decision["decisions_ue"] and decision["decisions_ue_descr"]:
+        params["decision_ue_txt"] = (
+            """<b>Unité%(decisions_ue_descr_plural)s d'Enseignement %(decision_orig)s capitalisée%(decisions_ue_descr_plural)s : %(decisions_ue_descr)s</b>"""
+            % params
+        )
+    else:
+        params["decision_ue_txt"] = ""
+    # Mention
+    params["mention"] = decision["mention"]
+    # Informations sur compensations
+    if decision["observation"]:
+        params["observation_txt"] = (
+            """<b>Observation :</b> %(observation)s.""" % decision
+        )
+    else:
+        params["observation_txt"] = ""
+    # Autorisations de passage
+    if decision["autorisations"] and not Se.parcours_validated():
+        if len(decision["autorisations"]) > 1:
+            s = "s"
+        else:
+            s = ""
+        params[
+            "autorisations_txt"
+        ] = """Vous êtes autorisé%s à continuer dans le%s semestre%s : <b>%s</b>""" % (
+            etud.e,
+            s,
+            s,
+            decision["autorisations_descr"],
+        )
+    else:
+        params["autorisations_txt"] = ""
+
+    if decision["decision_sem"] and Se.parcours_validated():
+        params["diplome_txt"] = (
+            """Vous avez donc obtenu le diplôme : <b>%(titre_formation)s</b>""" % params
+        )
+    else:
+        params["diplome_txt"] = ""
+
+    # Les fonctions ci-dessous ajoutent ou modifient des champs:
+    if formsemestre.formation.is_apc():
+        # ajout champs spécifiques PV BUT
+        add_apc_infos(formsemestre, params, decision)
+    else:
+        # ajout champs spécifiques PV DUT
+        add_classic_infos(formsemestre, params, decision)
+
+    # Corps de la lettre:
+    objects += sco_bulletins_pdf.process_field(
+        sco_preferences.get_preference("PV_LETTER_TEMPLATE", sem["formsemestre_id"]),
+        params,
+        style,
+        suppress_empty_pars=True,
+    )
+
+    # Signature:
+    # nota: si semestre terminal, signature par directeur IUT, sinon, signature par
+    # chef de département.
+    if Se.semestre_non_terminal:
+        sig = (
+            sco_preferences.get_preference(
+                "PV_LETTER_PASSAGE_SIGNATURE", formsemestre_id
+            )
+            or ""
+        ) % params
+        sig = _simulate_br(sig, '<para leftindent="%(htab1)s">')
+        objects += sco_pdf.make_paras(
+            (
+                """<para leftindent="%(htab1)s" spaceBefore="25mm">"""
+                + sig
+                + """</para>"""
+            )
+            % params,
+            style,
+        )
+    else:
+        sig = (
+            sco_preferences.get_preference(
+                "PV_LETTER_DIPLOMA_SIGNATURE", formsemestre_id
+            )
+            or ""
+        ) % params
+        sig = _simulate_br(sig, '<para leftindent="%(htab1)s">')
+        objects += sco_pdf.make_paras(
+            (
+                """<para leftindent="%(htab1)s" spaceBefore="25mm">"""
+                + sig
+                + """</para>"""
+            )
+            % params,
+            style,
+        )
+
+    if signature:
+        try:
+            objects.append(
+                _make_signature_image(signature, params["htab1"], formsemestre_id)
+            )
+        except UnidentifiedImageError as exc:
+            raise ScoValueError("Image signature invalide !") from exc
+
+    return objects
+
+
+def add_classic_infos(formsemestre: FormSemestre, params: dict, decision: dict):
+    """Ajoute les champs pour les formations classiques, donc avec codes semestres"""
+    if decision["prev_decision_sem"]:
+        params["prev_code_descr"] = decision["prev_code_descr"]
+        params[
+            "prev_decision_sem_txt"
+        ] = f"""<b>Décision du semestre antérieur S{params['prev_semestre_id']} :</b> {
+            params['prev_code_descr']}"""
+    # Décision semestre courant:
+    if formsemestre.semestre_id >= 0:
+        params["decision_orig"] = f"du semestre S{formsemestre.semestre_id}"
+    else:
+        params["decision_orig"] = ""
+
+
+def add_apc_infos(formsemestre: FormSemestre, params: dict, decision: dict):
+    """Ajoute les champs pour les formations APC (BUT), donc avec codes RCUE et année"""
+    annee_but = (formsemestre.semestre_id + 1) // 2
+    params["decision_orig"] = f"année BUT{annee_but}"
+    if decision is None:
+        params["decision_sem_descr"] = ""
+        params["decision_ue_txt"] = ""
+    else:
+        decision_annee = decision.get("decision_annee") or {}
+        params["decision_sem_descr"] = decision_annee.get("code") or ""
+        params[
+            "decision_ue_txt"
+        ] = f"""{params["decision_ue_txt"]}<br/>
+            <b>Niveaux de compétences:</b><br/> {decision.get("descr_decisions_rcue") or ""}
+        """
diff --git a/app/scodoc/sco_pv_pdf.py b/app/scodoc/sco_pv_pdf.py
new file mode 100644
index 000000000..c9473ba1e
--- /dev/null
+++ b/app/scodoc/sco_pv_pdf.py
@@ -0,0 +1,340 @@
+# -*- mode: python -*-
+# -*- coding: utf-8 -*-
+
+##############################################################################
+#
+# Gestion scolarite IUT
+#
+# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+#   Emmanuel Viennet      emmanuel.viennet@viennet.net
+#
+##############################################################################
+
+"""Génération du PV de jury en PDF (celui en format paysage avec l'ensemble des décisions)
+"""
+import io
+
+import reportlab
+from reportlab.lib.units import cm, mm
+from reportlab.lib.enums import TA_JUSTIFY
+from reportlab.platypus import (
+    Paragraph,
+    Spacer,
+    PageBreak,
+    Table,
+)
+from reportlab.platypus.doctemplate import BaseDocTemplate
+from reportlab.lib.pagesizes import A4, landscape
+from reportlab.lib import styles
+from reportlab.lib.colors import Color
+
+from app.models import FormSemestre
+
+from app.scodoc import codes_cursus
+from app.scodoc import sco_pv_dict
+from app.scodoc import sco_pdf
+from app.scodoc import sco_preferences
+from app.scodoc.sco_pdf import SU
+from app.scodoc.sco_pv_templates import PVTemplate, jury_titres
+import sco_version
+
+# ----------------------------------------------
+def pvjury_pdf(
+    formsemestre: FormSemestre,
+    etudids: list[int],
+    date_commission=None,
+    date_jury=None,
+    numero_arrete=None,
+    code_vdi=None,
+    show_title=False,
+    pv_title=None,
+    with_paragraph_nom=False,
+    anonymous=False,
+) -> bytes:
+    """Doc PDF récapitulant les décisions de jury
+    (tableau en format paysage)
+    """
+    objects, a_diplome = _pvjury_pdf_type(
+        formsemestre,
+        etudids,
+        only_diplome=False,
+        date_commission=date_commission,
+        numero_arrete=numero_arrete,
+        code_vdi=code_vdi,
+        date_jury=date_jury,
+        show_title=show_title,
+        pv_title=pv_title,
+        with_paragraph_nom=with_paragraph_nom,
+        anonymous=anonymous,
+    )
+    if not objects:
+        return b""
+
+    jury_de_diplome = formsemestre.est_terminal()
+
+    # Si Jury de passage et qu'un étudiant valide le parcours
+    #  (car il a validé antérieurement le dernier semestre)
+    # alors on génère aussi un PV de diplome (à la suite dans le même doc PDF)
+    if not jury_de_diplome and a_diplome:
+        # au moins un etudiant a validé son diplome:
+        objects.append(PageBreak())
+        objects += _pvjury_pdf_type(
+            formsemestre,
+            etudids,
+            only_diplome=True,
+            date_commission=date_commission,
+            date_jury=date_jury,
+            numero_arrete=numero_arrete,
+            code_vdi=code_vdi,
+            show_title=show_title,
+            pv_title=pv_title,
+            with_paragraph_nom=with_paragraph_nom,
+            anonymous=anonymous,
+        )[0]
+
+    # ----- Build PDF
+    report = io.BytesIO()  # in-memory document, no disk file
+    document = BaseDocTemplate(report)
+    document.pagesize = landscape(A4)
+    document.addPageTemplates(
+        PVTemplate(
+            document,
+            author=f"{sco_version.SCONAME} {sco_version.SCOVERSION} (E. Viennet)",
+            title=SU(f"PV du jury de {formsemestre.titre_num()}"),
+            subject="PV jury",
+            preferences=sco_preferences.SemPreferences(formsemestre.id),
+        )
+    )
+
+    document.build(objects)
+    data = report.getvalue()
+    return data
+
+
+def _make_pv_styles(formsemestre: FormSemestre):
+    style = reportlab.lib.styles.ParagraphStyle({})
+    style.fontSize = 12
+    style.fontName = sco_preferences.get_preference("PV_FONTNAME", formsemestre.id)
+    style.leading = 18
+    style.alignment = TA_JUSTIFY
+
+    indent = 1 * cm
+    style_bullet = reportlab.lib.styles.ParagraphStyle({})
+    style_bullet.fontSize = 12
+    style_bullet.fontName = sco_preferences.get_preference(
+        "PV_FONTNAME", formsemestre.id
+    )
+    style_bullet.leading = 12
+    style_bullet.alignment = TA_JUSTIFY
+    style_bullet.firstLineIndent = 0
+    style_bullet.leftIndent = indent
+    style_bullet.bulletIndent = indent
+    style_bullet.bulletFontName = "Times-Roman"
+    style_bullet.bulletFontSize = 11
+    style_bullet.spaceBefore = 5 * mm
+    style_bullet.spaceAfter = 5 * mm
+    return style, style_bullet
+
+
+def _pvjury_pdf_type(
+    formsemestre: FormSemestre,
+    etudids: list[int],
+    only_diplome=False,
+    date_commission=None,
+    date_jury=None,
+    numero_arrete=None,
+    code_vdi=None,
+    show_title=False,
+    pv_title=None,
+    anonymous=False,
+    with_paragraph_nom=False,
+) -> tuple[list, bool]:
+    """Objets platypus PDF récapitulant les décisions de jury
+    pour un type de jury (passage ou delivrance).
+    Ramene: liste d'onj platypus, et un boolen indiquant si au moins un étudiant est diplômé.
+    """
+    from app.scodoc import sco_pv_forms
+    from app.but import jury_but_pv
+
+    a_diplome = False
+    # Jury de diplome si sem. terminal OU que l'on demande seulement les diplomés
+    diplome = formsemestre.est_terminal() or only_diplome
+    titre_jury, _ = jury_titres(formsemestre, diplome)
+    titre_diplome = pv_title or formsemestre.formation.titre_officiel
+    objects = []
+
+    style, style_bullet = _make_pv_styles(formsemestre)
+
+    objects += [Spacer(0, 5 * mm)]
+    objects += sco_pdf.make_paras(
+        f"""
+    <para align="center"><b>Procès-verbal de {titre_jury} du département {
+        sco_preferences.get_preference("DeptName", formsemestre.id) or "(sans nom)"
+    } - Session unique {formsemestre.annee_scolaire()}</b></para>
+    """,
+        style,
+    )
+
+    objects += sco_pdf.make_paras(
+        f"""<para align="center"><b><i>{titre_diplome}</i></b></para>""",
+        style,
+    )
+
+    if show_title:
+        objects += sco_pdf.make_paras(
+            f"""<para align="center"><b>Semestre: {formsemestre.titre}</b></para>""",
+            style,
+        )
+    if sco_preferences.get_preference("PV_TITLE_WITH_VDI", formsemestre.id):
+        objects += sco_pdf.make_paras(
+            f"""<para align="center">VDI et Code: {(code_vdi or "")}</para>""", style
+        )
+
+    if date_jury:
+        objects += sco_pdf.make_paras(
+            f"""<para align="center">Jury tenu le {date_jury}</para>""", style
+        )
+
+    objects += sco_pdf.make_paras(
+        "<para>"
+        + (sco_preferences.get_preference("PV_INTRO", formsemestre.id) or "")
+        % {
+            "Decnum": numero_arrete,
+            "VDICode": code_vdi,
+            "UnivName": sco_preferences.get_preference("UnivName", formsemestre.id),
+            "Type": titre_jury,
+            "Date": date_commission,  # deprecated
+            "date_commission": date_commission,
+        }
+        + "</para>",
+        style_bullet,
+    )
+
+    objects += sco_pdf.make_paras(
+        """<para>Le jury propose les décisions suivantes :</para>""", style
+    )
+    objects += [Spacer(0, 4 * mm)]
+
+    if formsemestre.formation.is_apc():
+        rows, titles = jury_but_pv.pvjury_table_but(
+            formsemestre, etudids=etudids, line_sep="<br/>"
+        )
+        columns_ids = list(titles.keys())
+        a_diplome = codes_cursus.ADM in [row.get("diplome") for row in rows]
+    else:
+        dpv = sco_pv_dict.dict_pvjury(formsemestre.id, etudids=etudids, with_prev=True)
+        if not dpv:
+            return [], False
+        rows, titles, columns_ids = sco_pv_forms.pvjury_table(
+            dpv,
+            only_diplome=only_diplome,
+            anonymous=anonymous,
+            with_paragraph_nom=with_paragraph_nom,
+        )
+        a_diplome = True in (x["validation_parcours"] for x in dpv["decisions"])
+    # convert to lists of tuples:
+    columns_ids = ["etudid"] + columns_ids
+    rows = [[line.get(x, "") for x in columns_ids] for line in rows]
+    titles = [titles.get(x, "") for x in columns_ids]
+    # Make a new cell style and put all cells in paragraphs
+    cell_style = styles.ParagraphStyle({})
+    cell_style.fontSize = sco_preferences.get_preference(
+        "SCOLAR_FONT_SIZE", formsemestre.id
+    )
+    cell_style.fontName = sco_preferences.get_preference("PV_FONTNAME", formsemestre.id)
+    cell_style.leading = 1.0 * sco_preferences.get_preference(
+        "SCOLAR_FONT_SIZE", formsemestre.id
+    )  # vertical space
+    LINEWIDTH = 0.5
+    table_style = [
+        (
+            "FONTNAME",
+            (0, 0),
+            (-1, 0),
+            sco_preferences.get_preference("PV_FONTNAME", formsemestre.id),
+        ),
+        ("LINEBELOW", (0, 0), (-1, 0), LINEWIDTH, Color(0, 0, 0)),
+        ("GRID", (0, 0), (-1, -1), LINEWIDTH, Color(0, 0, 0)),
+        ("VALIGN", (0, 0), (-1, -1), "TOP"),
+    ]
+    titles = [f"<para><b>{x}</b></para>" for x in titles]
+
+    def _format_pv_cell(x):
+        """convert string to paragraph"""
+        if isinstance(x, str):
+            return Paragraph(SU(x), cell_style)
+        else:
+            return x
+
+    widths_by_id = {
+        "nom": 5 * cm,
+        "cursus": 2.8 * cm,
+        "ects": 1.4 * cm,
+        "devenir": 1.8 * cm,
+        "decision_but": 1.8 * cm,
+    }
+
+    table_cells = [[_format_pv_cell(x) for x in line[1:]] for line in ([titles] + rows)]
+    widths = [widths_by_id.get(col_id) for col_id in columns_ids[1:]]
+
+    objects.append(
+        Table(table_cells, repeatRows=1, colWidths=widths, style=table_style)
+    )
+
+    # Signature du directeur
+    objects += sco_pdf.make_paras(
+        f"""<para spaceBefore="10mm" align="right">{
+            sco_preferences.get_preference("DirectorName", formsemestre.id) or ""
+        }, {
+            sco_preferences.get_preference("DirectorTitle", formsemestre.id) or ""
+        }</para>""",
+        style,
+    )
+
+    # Légende des codes
+    codes = list(codes_cursus.CODES_EXPL.keys())
+    codes.sort()
+    objects += sco_pdf.make_paras(
+        """<para spaceBefore="15mm" fontSize="14">
+    <b>Codes utilisés :</b></para>""",
+        style,
+    )
+    L = []
+    for code in codes:
+        L.append((code, codes_cursus.CODES_EXPL[code]))
+    TableStyle2 = [
+        (
+            "FONTNAME",
+            (0, 0),
+            (-1, 0),
+            sco_preferences.get_preference("PV_FONTNAME", formsemestre.id),
+        ),
+        ("LINEBELOW", (0, 0), (-1, -1), LINEWIDTH, Color(0, 0, 0)),
+        ("LINEABOVE", (0, 0), (-1, -1), LINEWIDTH, Color(0, 0, 0)),
+        ("LINEBEFORE", (0, 0), (0, -1), LINEWIDTH, Color(0, 0, 0)),
+        ("LINEAFTER", (-1, 0), (-1, -1), LINEWIDTH, Color(0, 0, 0)),
+    ]
+    objects.append(
+        Table(
+            [[Paragraph(SU(x), cell_style) for x in line] for line in L],
+            colWidths=(2 * cm, None),
+            style=TableStyle2,
+        )
+    )
+
+    return objects, a_diplome
diff --git a/app/scodoc/sco_pv_templates.py b/app/scodoc/sco_pv_templates.py
new file mode 100644
index 000000000..da1c963a7
--- /dev/null
+++ b/app/scodoc/sco_pv_templates.py
@@ -0,0 +1,344 @@
+# -*- mode: python -*-
+# -*- coding: utf-8 -*-
+
+##############################################################################
+#
+# Gestion scolarite IUT
+#
+# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+#   Emmanuel Viennet      emmanuel.viennet@viennet.net
+#
+##############################################################################
+
+"""Edition des PV de jury
+"""
+import io
+import re
+
+from PIL import Image as PILImage
+from PIL import UnidentifiedImageError
+
+import reportlab
+from reportlab.lib.units import cm, mm
+from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_JUSTIFY
+from reportlab.platypus import Paragraph, Spacer, Frame, PageBreak
+from reportlab.platypus import Table, TableStyle, Image
+from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate
+from reportlab.lib.pagesizes import A4, landscape
+from reportlab.lib import styles
+from reportlab.lib.colors import Color
+
+from flask import g
+from app.models import FormSemestre
+
+import app.scodoc.sco_utils as scu
+from app.scodoc import sco_pdf
+from app.scodoc.sco_logos import find_logo
+from app.scodoc.sco_pdf import SU
+
+LOGO_FOOTER_ASPECT = scu.CONFIG.LOGO_FOOTER_ASPECT  # XXX A AUTOMATISER
+LOGO_FOOTER_HEIGHT = scu.CONFIG.LOGO_FOOTER_HEIGHT * mm
+LOGO_FOOTER_WIDTH = LOGO_FOOTER_HEIGHT * scu.CONFIG.LOGO_FOOTER_ASPECT
+
+LOGO_HEADER_ASPECT = scu.CONFIG.LOGO_HEADER_ASPECT  # XXX logo IUTV (A AUTOMATISER)
+LOGO_HEADER_HEIGHT = scu.CONFIG.LOGO_HEADER_HEIGHT * mm
+LOGO_HEADER_WIDTH = LOGO_HEADER_HEIGHT * scu.CONFIG.LOGO_HEADER_ASPECT
+
+
+def page_footer(canvas, doc, logo, preferences, with_page_numbers=True):
+    "Add footer on page"
+    width = doc.pagesize[0]  # - doc.pageTemplate.left_p - doc.pageTemplate.right_p
+    foot = Frame(
+        0.1 * mm,
+        0.2 * cm,
+        width - 1 * mm,
+        2 * cm,
+        leftPadding=0,
+        rightPadding=0,
+        topPadding=0,
+        bottomPadding=0,
+        id="monfooter",
+        showBoundary=0,
+    )
+
+    left_foot_style = reportlab.lib.styles.ParagraphStyle({})
+    left_foot_style.fontName = preferences["SCOLAR_FONT"]
+    left_foot_style.fontSize = preferences["SCOLAR_FONT_SIZE_FOOT"]
+    left_foot_style.leftIndent = 0
+    left_foot_style.firstLineIndent = 0
+    left_foot_style.alignment = TA_RIGHT
+    right_foot_style = reportlab.lib.styles.ParagraphStyle({})
+    right_foot_style.fontName = preferences["SCOLAR_FONT"]
+    right_foot_style.fontSize = preferences["SCOLAR_FONT_SIZE_FOOT"]
+    right_foot_style.alignment = TA_RIGHT
+
+    p = sco_pdf.make_paras(
+        f"""<para>{preferences["INSTITUTION_NAME"]}</para><para>{
+            preferences["INSTITUTION_ADDRESS"]}</para>""",
+        left_foot_style,
+    )
+
+    np = Paragraph(f'<para fontSize="14">{doc.page}</para>', right_foot_style)
+    tabstyle = TableStyle(
+        [
+            ("LEFTPADDING", (0, 0), (-1, -1), 0),
+            ("RIGHTPADDING", (0, 0), (-1, -1), 0),
+            ("ALIGN", (0, 0), (-1, -1), "RIGHT"),
+            # ('INNERGRID', (0,0), (-1,-1), 0.25, black),#debug
+            # ('LINEABOVE', (0,0), (-1,0), 0.5, black),
+            ("VALIGN", (1, 0), (1, 0), "MIDDLE"),
+            ("RIGHTPADDING", (-1, 0), (-1, 0), 1 * cm),
+        ]
+    )
+    elems = [p]
+    if logo:
+        elems.append(logo)
+    col_widths = [None, LOGO_FOOTER_WIDTH + 2 * mm]
+    if with_page_numbers:
+        elems.append(np)
+        col_widths.append(2 * cm)
+    else:
+        elems.append("")
+        col_widths.append(8 * mm)  # force marge droite
+    tab = Table([elems], style=tabstyle, colWidths=col_widths)
+    canvas.saveState()  # is it necessary ?
+    foot.addFromList([tab], canvas)
+    canvas.restoreState()
+
+
+def page_header(canvas, doc, logo, preferences, only_on_first_page=False):
+    "Ajoute au canvas le frame avec le logo"
+    if only_on_first_page and int(doc.page) > 1:
+        return
+    height = doc.pagesize[1]
+    head = Frame(
+        -22 * mm,
+        height - 13 * mm - LOGO_HEADER_HEIGHT,
+        10 * cm,
+        LOGO_HEADER_HEIGHT + 2 * mm,
+        leftPadding=0,
+        rightPadding=0,
+        topPadding=0,
+        bottomPadding=0,
+        id="monheader",
+        showBoundary=0,
+    )
+    if logo:
+        canvas.saveState()  # is it necessary ?
+        head.addFromList([logo], canvas)
+        canvas.restoreState()
+
+
+class CourrierIndividuelTemplate(PageTemplate):
+    """Template pour courrier avisant des decisions de jury (1 page par étudiant)"""
+
+    def __init__(
+        self,
+        document,
+        pagesbookmarks=None,
+        author=None,
+        title=None,
+        subject=None,
+        margins=(0, 0, 0, 0),  # additional margins in mm (left,top,right, bottom)
+        preferences=None,  # dictionnary with preferences, required
+        force_header=False,
+        force_footer=False,  # always add a footer (whatever the preferences, use for PV)
+        template_name="CourrierJuryTemplate",
+    ):
+        """Initialise our page template."""
+        self.pagesbookmarks = pagesbookmarks or {}
+        self.pdfmeta_author = author
+        self.pdfmeta_title = title
+        self.pdfmeta_subject = subject
+        self.preferences = preferences
+        self.force_header = force_header
+        self.force_footer = force_footer
+        self.with_footer = (
+            self.force_footer or self.preferences["PV_LETTER_WITH_HEADER"]
+        )
+        self.with_header = (
+            self.force_header or self.preferences["PV_LETTER_WITH_FOOTER"]
+        )
+        self.with_page_background = self.preferences["PV_LETTER_WITH_BACKGROUND"]
+        self.with_page_numbers = False
+        self.header_only_on_first_page = False
+        # Our doc is made of a single frame
+        left, top, right, bottom = margins  # marge additionnelle en mm
+        # marges du Frame principal
+        self.bot_p = 2 * cm
+        self.left_p = 2.5 * cm
+        self.right_p = 2.5 * cm
+        self.top_p = 0 * cm
+        # log("margins=%s" % str(margins))
+        content = Frame(
+            self.left_p + left * mm,
+            self.bot_p + bottom * mm,
+            document.pagesize[0] - self.right_p - self.left_p - left * mm - right * mm,
+            document.pagesize[1] - self.top_p - self.bot_p - top * mm - bottom * mm,
+        )
+
+        PageTemplate.__init__(self, template_name, [content])
+
+        self.background_image_filename = None
+        self.logo_footer = None
+        self.logo_header = None
+        # Search logos in dept specific dir, then in global scu.CONFIG dir
+        if template_name == "PVJuryTemplate":
+            background = find_logo(
+                logoname="pvjury_background",
+                dept_id=g.scodoc_dept_id,
+            ) or find_logo(
+                logoname="pvjury_background",
+                dept_id=g.scodoc_dept_id,
+                prefix="",
+            )
+        else:
+            background = find_logo(
+                logoname="letter_background",
+                dept_id=g.scodoc_dept_id,
+            ) or find_logo(
+                logoname="letter_background",
+                dept_id=g.scodoc_dept_id,
+                prefix="",
+            )
+        if not self.background_image_filename and background is not None:
+            self.background_image_filename = background.filepath
+
+        footer = find_logo(logoname="footer", dept_id=g.scodoc_dept_id)
+        if footer is not None:
+            self.logo_footer = Image(
+                footer.filepath,
+                height=LOGO_FOOTER_HEIGHT,
+                width=LOGO_FOOTER_WIDTH,
+            )
+
+        header = find_logo(logoname="header", dept_id=g.scodoc_dept_id)
+        if header is not None:
+            self.logo_header = Image(
+                header.filepath,
+                height=LOGO_HEADER_HEIGHT,
+                width=LOGO_HEADER_WIDTH,
+            )
+
+    def beforeDrawPage(self, canv, doc):
+        """Draws a logo and an contribution message on each page."""
+        # ---- Add some meta data and bookmarks
+        if self.pdfmeta_author:
+            canv.setAuthor(SU(self.pdfmeta_author))
+        if self.pdfmeta_title:
+            canv.setTitle(SU(self.pdfmeta_title))
+        if self.pdfmeta_subject:
+            canv.setSubject(SU(self.pdfmeta_subject))
+        bm = self.pagesbookmarks.get(doc.page, None)
+        if bm is not None:
+            key = bm
+            txt = SU(bm)
+            canv.bookmarkPage(key)
+            canv.addOutlineEntry(txt, bm)
+
+        # ---- Background image
+        if self.background_image_filename and self.with_page_background:
+            canv.drawImage(
+                self.background_image_filename, 0, 0, doc.pagesize[0], doc.pagesize[1]
+            )
+
+        # ---- Header/Footer
+        if self.with_header:
+            page_header(
+                canv,
+                doc,
+                self.logo_header,
+                self.preferences,
+                self.header_only_on_first_page,
+            )
+        if self.with_footer:
+            page_footer(
+                canv,
+                doc,
+                self.logo_footer,
+                self.preferences,
+                with_page_numbers=self.with_page_numbers,
+            )
+
+
+class PVTemplate(CourrierIndividuelTemplate):
+    """Template pour les pages des PV de jury"""
+
+    def __init__(
+        self,
+        document,
+        author=None,
+        title=None,
+        subject=None,
+        margins=None,  # additional margins in mm (left,top,right, bottom)
+        preferences=None,  # dictionnary with preferences, required
+    ):
+        if margins is None:
+            margins = (
+                preferences["pv_left_margin"],
+                preferences["pv_top_margin"],
+                preferences["pv_right_margin"],
+                preferences["pv_bottom_margin"],
+            )
+        super().__init__(
+            document,
+            author=author,
+            title=title,
+            subject=subject,
+            margins=margins,
+            preferences=preferences,
+            force_header=True,
+            force_footer=True,
+            template_name="PVJuryTemplate",
+        )
+        self.with_page_numbers = True
+        self.header_only_on_first_page = True
+        self.with_header = self.preferences["PV_WITH_HEADER"]
+        self.with_footer = self.preferences["PV_WITH_FOOTER"]
+        self.with_page_background = self.preferences["PV_WITH_BACKGROUND"]
+
+    # def afterDrawPage(self, canv, doc):
+    #     """Called after all flowables have been drawn on a page"""
+    #     pass
+
+    # def beforeDrawPage(self, canv, doc):
+    #     """Called before any flowables are drawn on a page"""
+    #     # If the page number is even, force a page break
+    #     super().beforeDrawPage(canv, doc)
+    #     # Note: on cherche un moyen de generer un saut de page double
+    #     #  (redémarrer sur page impaire, nouvelle feuille en recto/verso). Pas trouvé en Platypus.
+    #     #
+    #     # if self.__pageNum % 2 == 0:
+    #     #    canvas.showPage()
+    #     #    # Increment pageNum again since we've added a blank page
+    #     #    self.__pageNum += 1
+
+
+def jury_titres(formsemestre: FormSemestre, diplome: bool) -> tuple[str, str]:
+    """Titres du PV ou lettre de jury"""
+    if not diplome:
+        if formsemestre.formation.is_apc():
+            t = f"""BUT{(formsemestre.semestre_id+1)//2}"""
+            s = t
+        else:
+            t = f"""passage de Semestre {formsemestre.semestre_id} en Semestre {formsemestre.semestre_id + 1}"""
+            s = "passage de semestre"
+    else:
+        t = "délivrance du diplôme"
+        s = t
+    return t, s  # titre long, titre court
diff --git a/app/scodoc/sco_pvpdf.py b/app/scodoc/sco_pvpdf.py
deleted file mode 100644
index deeea3933..000000000
--- a/app/scodoc/sco_pvpdf.py
+++ /dev/null
@@ -1,942 +0,0 @@
-# -*- mode: python -*-
-# -*- coding: utf-8 -*-
-
-##############################################################################
-#
-# Gestion scolarite IUT
-#
-# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
-#
-#   Emmanuel Viennet      emmanuel.viennet@viennet.net
-#
-##############################################################################
-
-"""Edition des PV de jury
-"""
-import io
-import re
-
-from PIL import Image as PILImage
-from PIL import UnidentifiedImageError
-
-import reportlab
-from reportlab.lib.units import cm, mm
-from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_JUSTIFY
-from reportlab.platypus import Paragraph, Spacer, Frame, PageBreak
-from reportlab.platypus import Table, TableStyle, Image
-from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate
-from reportlab.lib.pagesizes import A4, landscape
-from reportlab.lib import styles
-from reportlab.lib.colors import Color
-
-from flask import g
-from app.models import FormSemestre, Identite
-
-import app.scodoc.sco_utils as scu
-from app.scodoc import sco_bulletins_pdf
-from app.scodoc import codes_cursus
-from app.scodoc import sco_dict_pv_jury
-from app.scodoc import sco_etud
-from app.scodoc import sco_pdf
-from app.scodoc import sco_preferences
-from app.scodoc.sco_exceptions import ScoValueError
-from app.scodoc.sco_logos import find_logo
-from app.scodoc.sco_cursus_dut import SituationEtudCursus
-from app.scodoc.sco_pdf import SU
-import sco_version
-
-LOGO_FOOTER_ASPECT = scu.CONFIG.LOGO_FOOTER_ASPECT  # XXX A AUTOMATISER
-LOGO_FOOTER_HEIGHT = scu.CONFIG.LOGO_FOOTER_HEIGHT * mm
-LOGO_FOOTER_WIDTH = LOGO_FOOTER_HEIGHT * scu.CONFIG.LOGO_FOOTER_ASPECT
-
-LOGO_HEADER_ASPECT = scu.CONFIG.LOGO_HEADER_ASPECT  # XXX logo IUTV (A AUTOMATISER)
-LOGO_HEADER_HEIGHT = scu.CONFIG.LOGO_HEADER_HEIGHT * mm
-LOGO_HEADER_WIDTH = LOGO_HEADER_HEIGHT * scu.CONFIG.LOGO_HEADER_ASPECT
-
-
-def page_footer(canvas, doc, logo, preferences, with_page_numbers=True):
-    "Add footer on page"
-    width = doc.pagesize[0]  # - doc.pageTemplate.left_p - doc.pageTemplate.right_p
-    foot = Frame(
-        0.1 * mm,
-        0.2 * cm,
-        width - 1 * mm,
-        2 * cm,
-        leftPadding=0,
-        rightPadding=0,
-        topPadding=0,
-        bottomPadding=0,
-        id="monfooter",
-        showBoundary=0,
-    )
-
-    left_foot_style = reportlab.lib.styles.ParagraphStyle({})
-    left_foot_style.fontName = preferences["SCOLAR_FONT"]
-    left_foot_style.fontSize = preferences["SCOLAR_FONT_SIZE_FOOT"]
-    left_foot_style.leftIndent = 0
-    left_foot_style.firstLineIndent = 0
-    left_foot_style.alignment = TA_RIGHT
-    right_foot_style = reportlab.lib.styles.ParagraphStyle({})
-    right_foot_style.fontName = preferences["SCOLAR_FONT"]
-    right_foot_style.fontSize = preferences["SCOLAR_FONT_SIZE_FOOT"]
-    right_foot_style.alignment = TA_RIGHT
-
-    p = sco_pdf.make_paras(
-        f"""<para>{preferences["INSTITUTION_NAME"]}</para><para>{
-            preferences["INSTITUTION_ADDRESS"]}</para>""",
-        left_foot_style,
-    )
-
-    np = Paragraph(f'<para fontSize="14">{doc.page}</para>', right_foot_style)
-    tabstyle = TableStyle(
-        [
-            ("LEFTPADDING", (0, 0), (-1, -1), 0),
-            ("RIGHTPADDING", (0, 0), (-1, -1), 0),
-            ("ALIGN", (0, 0), (-1, -1), "RIGHT"),
-            # ('INNERGRID', (0,0), (-1,-1), 0.25, black),#debug
-            # ('LINEABOVE', (0,0), (-1,0), 0.5, black),
-            ("VALIGN", (1, 0), (1, 0), "MIDDLE"),
-            ("RIGHTPADDING", (-1, 0), (-1, 0), 1 * cm),
-        ]
-    )
-    elems = [p]
-    if logo:
-        elems.append(logo)
-    colWidths = [None, LOGO_FOOTER_WIDTH + 2 * mm]
-    if with_page_numbers:
-        elems.append(np)
-        colWidths.append(2 * cm)
-    else:
-        elems.append("")
-        colWidths.append(8 * mm)  # force marge droite
-    tab = Table([elems], style=tabstyle, colWidths=colWidths)
-    canvas.saveState()  # is it necessary ?
-    foot.addFromList([tab], canvas)
-    canvas.restoreState()
-
-
-def page_header(canvas, doc, logo, preferences, only_on_first_page=False):
-    "Ajoute au canvas le frame avec le logo"
-    if only_on_first_page and int(doc.page) > 1:
-        return
-    height = doc.pagesize[1]
-    head = Frame(
-        -22 * mm,
-        height - 13 * mm - LOGO_HEADER_HEIGHT,
-        10 * cm,
-        LOGO_HEADER_HEIGHT + 2 * mm,
-        leftPadding=0,
-        rightPadding=0,
-        topPadding=0,
-        bottomPadding=0,
-        id="monheader",
-        showBoundary=0,
-    )
-    if logo:
-        canvas.saveState()  # is it necessary ?
-        head.addFromList([logo], canvas)
-        canvas.restoreState()
-
-
-class CourrierIndividuelTemplate(PageTemplate):
-    """Template pour courrier avisant des decisions de jury (1 page par étudiant)"""
-
-    def __init__(
-        self,
-        document,
-        pagesbookmarks=None,
-        author=None,
-        title=None,
-        subject=None,
-        margins=(0, 0, 0, 0),  # additional margins in mm (left,top,right, bottom)
-        preferences=None,  # dictionnary with preferences, required
-        force_header=False,
-        force_footer=False,  # always add a footer (whatever the preferences, use for PV)
-        template_name="CourrierJuryTemplate",
-    ):
-        """Initialise our page template."""
-        self.pagesbookmarks = pagesbookmarks or {}
-        self.pdfmeta_author = author
-        self.pdfmeta_title = title
-        self.pdfmeta_subject = subject
-        self.preferences = preferences
-        self.force_header = force_header
-        self.force_footer = force_footer
-        self.with_footer = (
-            self.force_footer or self.preferences["PV_LETTER_WITH_HEADER"]
-        )
-        self.with_header = (
-            self.force_header or self.preferences["PV_LETTER_WITH_FOOTER"]
-        )
-        self.with_page_background = self.preferences["PV_LETTER_WITH_BACKGROUND"]
-        self.with_page_numbers = False
-        self.header_only_on_first_page = False
-        # Our doc is made of a single frame
-        left, top, right, bottom = margins  # marge additionnelle en mm
-        # marges du Frame principal
-        self.bot_p = 2 * cm
-        self.left_p = 2.5 * cm
-        self.right_p = 2.5 * cm
-        self.top_p = 0 * cm
-        # log("margins=%s" % str(margins))
-        content = Frame(
-            self.left_p + left * mm,
-            self.bot_p + bottom * mm,
-            document.pagesize[0] - self.right_p - self.left_p - left * mm - right * mm,
-            document.pagesize[1] - self.top_p - self.bot_p - top * mm - bottom * mm,
-        )
-
-        PageTemplate.__init__(self, template_name, [content])
-
-        self.background_image_filename = None
-        self.logo_footer = None
-        self.logo_header = None
-        # Search logos in dept specific dir, then in global scu.CONFIG dir
-        if template_name == "PVJuryTemplate":
-            background = find_logo(
-                logoname="pvjury_background",
-                dept_id=g.scodoc_dept_id,
-            ) or find_logo(
-                logoname="pvjury_background",
-                dept_id=g.scodoc_dept_id,
-                prefix="",
-            )
-        else:
-            background = find_logo(
-                logoname="letter_background",
-                dept_id=g.scodoc_dept_id,
-            ) or find_logo(
-                logoname="letter_background",
-                dept_id=g.scodoc_dept_id,
-                prefix="",
-            )
-        if not self.background_image_filename and background is not None:
-            self.background_image_filename = background.filepath
-
-        footer = find_logo(logoname="footer", dept_id=g.scodoc_dept_id)
-        if footer is not None:
-            self.logo_footer = Image(
-                footer.filepath,
-                height=LOGO_FOOTER_HEIGHT,
-                width=LOGO_FOOTER_WIDTH,
-            )
-
-        header = find_logo(logoname="header", dept_id=g.scodoc_dept_id)
-        if header is not None:
-            self.logo_header = Image(
-                header.filepath,
-                height=LOGO_HEADER_HEIGHT,
-                width=LOGO_HEADER_WIDTH,
-            )
-
-    def beforeDrawPage(self, canv, doc):
-        """Draws a logo and an contribution message on each page."""
-        # ---- Add some meta data and bookmarks
-        if self.pdfmeta_author:
-            canv.setAuthor(SU(self.pdfmeta_author))
-        if self.pdfmeta_title:
-            canv.setTitle(SU(self.pdfmeta_title))
-        if self.pdfmeta_subject:
-            canv.setSubject(SU(self.pdfmeta_subject))
-        bm = self.pagesbookmarks.get(doc.page, None)
-        if bm != None:
-            key = bm
-            txt = SU(bm)
-            canv.bookmarkPage(key)
-            canv.addOutlineEntry(txt, bm)
-
-        # ---- Background image
-        if self.background_image_filename and self.with_page_background:
-            canv.drawImage(
-                self.background_image_filename, 0, 0, doc.pagesize[0], doc.pagesize[1]
-            )
-
-        # ---- Header/Footer
-        if self.with_header:
-            page_header(
-                canv,
-                doc,
-                self.logo_header,
-                self.preferences,
-                self.header_only_on_first_page,
-            )
-        if self.with_footer:
-            page_footer(
-                canv,
-                doc,
-                self.logo_footer,
-                self.preferences,
-                with_page_numbers=self.with_page_numbers,
-            )
-
-
-class PVTemplate(CourrierIndividuelTemplate):
-    """Template pour les pages des PV de jury"""
-
-    def __init__(
-        self,
-        document,
-        author=None,
-        title=None,
-        subject=None,
-        margins=None,  # additional margins in mm (left,top,right, bottom)
-        preferences=None,  # dictionnary with preferences, required
-    ):
-        if margins is None:
-            margins = (
-                preferences["pv_left_margin"],
-                preferences["pv_top_margin"],
-                preferences["pv_right_margin"],
-                preferences["pv_bottom_margin"],
-            )
-        CourrierIndividuelTemplate.__init__(
-            self,
-            document,
-            author=author,
-            title=title,
-            subject=subject,
-            margins=margins,
-            preferences=preferences,
-            force_header=True,
-            force_footer=True,
-            template_name="PVJuryTemplate",
-        )
-        self.with_page_numbers = True
-        self.header_only_on_first_page = True
-        self.with_header = self.preferences["PV_WITH_HEADER"]
-        self.with_footer = self.preferences["PV_WITH_FOOTER"]
-        self.with_page_background = self.preferences["PV_WITH_BACKGROUND"]
-
-    def afterDrawPage(self, canv, doc):
-        """Called after all flowables have been drawn on a page"""
-        pass
-
-    def beforeDrawPage(self, canv, doc):
-        """Called before any flowables are drawn on a page"""
-        # If the page number is even, force a page break
-        CourrierIndividuelTemplate.beforeDrawPage(self, canv, doc)
-        # Note: on cherche un moyen de generer un saut de page double
-        #  (redémarrer sur page impaire, nouvelle feuille en recto/verso). Pas trouvé en Platypus.
-        #
-        # if self.__pageNum % 2 == 0:
-        #    canvas.showPage()
-        #    # Increment pageNum again since we've added a blank page
-        #    self.__pageNum += 1
-
-
-def _simulate_br(paragraph_txt: str, para="<para>") -> str:
-    """Reportlab bug turnaround (could be removed in a future version).
-    p is a string with Reportlab intra-paragraph XML tags.
-    Replaces <br> (currently ignored by Reportlab) by </para><para>
-    Also replaces <br> by <br/>
-    """
-    return ("</para>" + para).join(
-        re.split(r"<.*?br.*?/>", paragraph_txt.replace("<br>", "<br/>"))
-    )
-
-
-def _make_signature_image(signature, leftindent, formsemestre_id) -> Table:
-    "crée un paragraphe avec l'image signature"
-    # cree une image PIL pour avoir la taille (W,H)
-
-    f = io.BytesIO(signature)
-    img = PILImage.open(f)
-    width, height = img.size
-    pdfheight = (
-        1.0
-        * sco_preferences.get_preference("pv_sig_image_height", formsemestre_id)
-        * mm
-    )
-    f.seek(0, 0)
-
-    style = styles.ParagraphStyle({})
-    style.leading = 1.0 * sco_preferences.get_preference(
-        "SCOLAR_FONT_SIZE", formsemestre_id
-    )  # vertical space
-    style.leftIndent = leftindent
-    return Table(
-        [("", Image(f, width=width * pdfheight / float(height), height=pdfheight))],
-        colWidths=(9 * cm, 7 * cm),
-    )
-
-
-def pdf_lettres_individuelles(
-    formsemestre_id,
-    etudids=None,
-    date_jury="",
-    date_commission="",
-    signature=None,
-):
-    """Document PDF avec les lettres d'avis pour les etudiants mentionnés
-    (tous ceux du semestre, ou la liste indiquée par etudids)
-    Renvoie pdf data ou chaine vide si aucun etudiant avec décision de jury.
-    """
-    dpv = sco_dict_pv_jury.dict_pvjury(formsemestre_id, etudids=etudids, with_prev=True)
-    if not dpv:
-        return ""
-    # Ajoute infos sur etudiants
-    etuds = [x["identite"] for x in dpv["decisions"]]
-    sco_etud.fill_etuds_info(etuds)
-    #
-    formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
-    prefs = sco_preferences.SemPreferences(formsemestre_id)
-    params = {
-        "date_jury": date_jury,
-        "date_commission": date_commission,
-        "titre_formation": dpv["formation"]["titre_officiel"],
-        "htab1": "8cm",  # lignes à droite (entete, signature)
-        "htab2": "1cm",
-    }
-    # copie preferences
-    for name in sco_preferences.get_base_preferences().prefs_name:
-        params[name] = sco_preferences.get_preference(name, formsemestre_id)
-
-    bookmarks = {}
-    objects = []  # list of PLATYPUS objects
-    npages = 0
-    for decision in dpv["decisions"]:
-        if (
-            decision["decision_sem"]
-            or decision.get("decision_annee")
-            or decision.get("decision_rcue")
-        ):  # decision prise
-            etud: Identite = Identite.query.get(decision["identite"]["etudid"])
-            params["nomEtud"] = etud.nomprenom
-            bookmarks[npages + 1] = scu.suppress_accents(etud.nomprenom)
-            try:
-                objects += pdf_lettre_individuelle(
-                    dpv["formsemestre"], decision, etud, params, signature
-                )
-            except UnidentifiedImageError as exc:
-                raise ScoValueError(
-                    "Fichier image (signature ou logo ?) invalide !"
-                ) from exc
-            objects.append(PageBreak())
-            npages += 1
-    if npages == 0:
-        return ""
-    # Paramètres de mise en page
-    margins = (
-        prefs["left_margin"],
-        prefs["top_margin"],
-        prefs["right_margin"],
-        prefs["bottom_margin"],
-    )
-
-    # ----- Build PDF
-    report = io.BytesIO()  # in-memory document, no disk file
-    document = BaseDocTemplate(report)
-    document.addPageTemplates(
-        CourrierIndividuelTemplate(
-            document,
-            author=f"{sco_version.SCONAME} {sco_version.SCOVERSION} (E. Viennet)",
-            title=f"Lettres décision {formsemestre.titre_annee()}",
-            subject="Décision jury",
-            margins=margins,
-            pagesbookmarks=bookmarks,
-            preferences=prefs,
-        )
-    )
-
-    document.build(objects)
-    data = report.getvalue()
-    return data
-
-
-def _descr_jury(formsemestre: FormSemestre, diplome):
-
-    if not diplome:
-        if formsemestre.formation.is_apc():
-            t = f"""BUT{(formsemestre.semestre_id+1)//2}"""
-            s = t
-        else:
-            t = f"""passage de Semestre {formsemestre.semestre_id} en Semestre {formsemestre.semestre_id + 1}"""
-            s = "passage de semestre"
-    else:
-        t = "délivrance du diplôme"
-        s = t
-    return t, s  # titre long, titre court
-
-
-def pdf_lettre_individuelle(sem, decision, etud: Identite, params, signature=None):
-    """
-    Renvoie une liste d'objets PLATYPUS pour intégration
-    dans un autre document.
-    """
-    #
-    formsemestre_id = sem["formsemestre_id"]
-    formsemestre = FormSemestre.query.get(formsemestre_id)
-    Se: SituationEtudCursus = decision["Se"]
-    t, s = _descr_jury(
-        formsemestre, Se.parcours_validated() or not Se.semestre_non_terminal
-    )
-    objects = []
-    style = reportlab.lib.styles.ParagraphStyle({})
-    style.fontSize = 14
-    style.fontName = sco_preferences.get_preference("PV_FONTNAME", formsemestre_id)
-    style.leading = 18
-    style.alignment = TA_LEFT
-
-    params["semestre_id"] = formsemestre.semestre_id
-    params["decision_sem_descr"] = decision["decision_sem_descr"]
-    params["type_jury"] = t  # type de jury (passage ou delivrance)
-    params["type_jury_abbrv"] = s  # idem, abbrégé
-    params["decisions_ue_descr"] = decision["decisions_ue_descr"]
-    if decision["decisions_ue_nb"] > 1:
-        params["decisions_ue_descr_plural"] = "s"
-    else:
-        params["decisions_ue_descr_plural"] = ""
-
-    params["INSTITUTION_CITY"] = (
-        sco_preferences.get_preference("INSTITUTION_CITY", formsemestre_id) or ""
-    )
-
-    if decision["prev_decision_sem"]:
-        params["prev_semestre_id"] = decision["prev"]["semestre_id"]
-
-    params["prev_decision_sem_txt"] = ""
-    params["decision_orig"] = ""
-
-    params.update(decision["identite"])
-    # fix domicile
-    if params["domicile"]:
-        params["domicile"] = params["domicile"].replace("\\n", "<br/>")
-
-    # UE capitalisées:
-    if decision["decisions_ue"] and decision["decisions_ue_descr"]:
-        params["decision_ue_txt"] = (
-            """<b>Unité%(decisions_ue_descr_plural)s d'Enseignement %(decision_orig)s capitalisée%(decisions_ue_descr_plural)s : %(decisions_ue_descr)s</b>"""
-            % params
-        )
-    else:
-        params["decision_ue_txt"] = ""
-    # Mention
-    params["mention"] = decision["mention"]
-    # Informations sur compensations
-    if decision["observation"]:
-        params["observation_txt"] = (
-            """<b>Observation :</b> %(observation)s.""" % decision
-        )
-    else:
-        params["observation_txt"] = ""
-    # Autorisations de passage
-    if decision["autorisations"] and not Se.parcours_validated():
-        if len(decision["autorisations"]) > 1:
-            s = "s"
-        else:
-            s = ""
-        params[
-            "autorisations_txt"
-        ] = """Vous êtes autorisé%s à continuer dans le%s semestre%s : <b>%s</b>""" % (
-            etud.e,
-            s,
-            s,
-            decision["autorisations_descr"],
-        )
-    else:
-        params["autorisations_txt"] = ""
-
-    if decision["decision_sem"] and Se.parcours_validated():
-        params["diplome_txt"] = (
-            """Vous avez donc obtenu le diplôme : <b>%(titre_formation)s</b>""" % params
-        )
-    else:
-        params["diplome_txt"] = ""
-
-    # Les fonctions ci-dessous ajoutent ou modifient des champs:
-    if formsemestre.formation.is_apc():
-        # ajout champs spécifiques PV BUT
-        add_apc_infos(formsemestre, params, decision)
-    else:
-        # ajout champs spécifiques PV DUT
-        add_classic_infos(formsemestre, params, decision)
-
-    # Corps de la lettre:
-    objects += sco_bulletins_pdf.process_field(
-        sco_preferences.get_preference("PV_LETTER_TEMPLATE", sem["formsemestre_id"]),
-        params,
-        style,
-        suppress_empty_pars=True,
-    )
-
-    # Signature:
-    # nota: si semestre terminal, signature par directeur IUT, sinon, signature par
-    # chef de département.
-    if Se.semestre_non_terminal:
-        sig = (
-            sco_preferences.get_preference(
-                "PV_LETTER_PASSAGE_SIGNATURE", formsemestre_id
-            )
-            or ""
-        ) % params
-        sig = _simulate_br(sig, '<para leftindent="%(htab1)s">')
-        objects += sco_pdf.make_paras(
-            (
-                """<para leftindent="%(htab1)s" spaceBefore="25mm">"""
-                + sig
-                + """</para>"""
-            )
-            % params,
-            style,
-        )
-    else:
-        sig = (
-            sco_preferences.get_preference(
-                "PV_LETTER_DIPLOMA_SIGNATURE", formsemestre_id
-            )
-            or ""
-        ) % params
-        sig = _simulate_br(sig, '<para leftindent="%(htab1)s">')
-        objects += sco_pdf.make_paras(
-            (
-                """<para leftindent="%(htab1)s" spaceBefore="25mm">"""
-                + sig
-                + """</para>"""
-            )
-            % params,
-            style,
-        )
-
-    if signature:
-        try:
-            objects.append(
-                _make_signature_image(signature, params["htab1"], formsemestre_id)
-            )
-        except UnidentifiedImageError as exc:
-            raise ScoValueError("Image signature invalide !") from exc
-
-    return objects
-
-
-def add_classic_infos(formsemestre: FormSemestre, params: dict, decision: dict):
-    """Ajoute les champs pour les formations classiques, donc avec codes semestres"""
-    if decision["prev_decision_sem"]:
-        params["prev_code_descr"] = decision["prev_code_descr"]
-        params[
-            "prev_decision_sem_txt"
-        ] = f"""<b>Décision du semestre antérieur S{params['prev_semestre_id']} :</b> {params['prev_code_descr']}"""
-    # Décision semestre courant:
-    if formsemestre.semestre_id >= 0:
-        params["decision_orig"] = f"du semestre S{formsemestre.semestre_id}"
-    else:
-        params["decision_orig"] = ""
-
-
-def add_apc_infos(formsemestre: FormSemestre, params: dict, decision: dict):
-    """Ajoute les champs pour les formations APC (BUT), donc avec codes RCUE et année"""
-    annee_but = (formsemestre.semestre_id + 1) // 2
-    params["decision_orig"] = f"année BUT{annee_but}"
-    if decision is None:
-        params["decision_sem_descr"] = ""
-        params["decision_ue_txt"] = ""
-    else:
-        decision_annee = decision.get("decision_annee") or {}
-        params["decision_sem_descr"] = decision_annee.get("code") or ""
-        params[
-            "decision_ue_txt"
-        ] = f"""{params["decision_ue_txt"]}<br/>
-            <b>Niveaux de compétences:</b><br/> {decision.get("descr_decisions_rcue") or ""}
-        """
-
-
-# ----------------------------------------------
-def pvjury_pdf(
-    formsemestre: FormSemestre,
-    etudids: list[int],
-    date_commission=None,
-    date_jury=None,
-    numero_arrete=None,
-    code_vdi=None,
-    show_title=False,
-    pv_title=None,
-    with_paragraph_nom=False,
-    anonymous=False,
-) -> bytes:
-    """Doc PDF récapitulant les décisions de jury
-    (tableau en format paysage)
-    """
-    objects, a_diplome = _pvjury_pdf_type(
-        formsemestre,
-        etudids,
-        only_diplome=False,
-        date_commission=date_commission,
-        numero_arrete=numero_arrete,
-        code_vdi=code_vdi,
-        date_jury=date_jury,
-        show_title=show_title,
-        pv_title=pv_title,
-        with_paragraph_nom=with_paragraph_nom,
-        anonymous=anonymous,
-    )
-    if not objects:
-        return b""
-
-    jury_de_diplome = formsemestre.est_terminal()
-
-    # Si Jury de passage et qu'un étudiant valide le parcours
-    #  (car il a validé antérieurement le dernier semestre)
-    # alors on génère aussi un PV de diplome (à la suite dans le même doc PDF)
-    if not jury_de_diplome and a_diplome:
-        # au moins un etudiant a validé son diplome:
-        objects.append(PageBreak())
-        objects += _pvjury_pdf_type(
-            formsemestre,
-            etudids,
-            only_diplome=True,
-            date_commission=date_commission,
-            date_jury=date_jury,
-            numero_arrete=numero_arrete,
-            code_vdi=code_vdi,
-            show_title=show_title,
-            pv_title=pv_title,
-            with_paragraph_nom=with_paragraph_nom,
-            anonymous=anonymous,
-        )[0]
-
-    # ----- Build PDF
-    report = io.BytesIO()  # in-memory document, no disk file
-    document = BaseDocTemplate(report)
-    document.pagesize = landscape(A4)
-    document.addPageTemplates(
-        PVTemplate(
-            document,
-            author=f"{sco_version.SCONAME} {sco_version.SCOVERSION} (E. Viennet)",
-            title=SU(f"PV du jury de {formsemestre.titre_num()}"),
-            subject="PV jury",
-            preferences=sco_preferences.SemPreferences(formsemestre.id),
-        )
-    )
-
-    document.build(objects)
-    data = report.getvalue()
-    return data
-
-
-def _make_pv_styles(formsemestre: FormSemestre):
-    style = reportlab.lib.styles.ParagraphStyle({})
-    style.fontSize = 12
-    style.fontName = sco_preferences.get_preference("PV_FONTNAME", formsemestre.id)
-    style.leading = 18
-    style.alignment = TA_JUSTIFY
-
-    indent = 1 * cm
-    style_bullet = reportlab.lib.styles.ParagraphStyle({})
-    style_bullet.fontSize = 12
-    style_bullet.fontName = sco_preferences.get_preference(
-        "PV_FONTNAME", formsemestre.id
-    )
-    style_bullet.leading = 12
-    style_bullet.alignment = TA_JUSTIFY
-    style_bullet.firstLineIndent = 0
-    style_bullet.leftIndent = indent
-    style_bullet.bulletIndent = indent
-    style_bullet.bulletFontName = "Times-Roman"
-    style_bullet.bulletFontSize = 11
-    style_bullet.spaceBefore = 5 * mm
-    style_bullet.spaceAfter = 5 * mm
-    return style, style_bullet
-
-
-def _pvjury_pdf_type(
-    formsemestre: FormSemestre,
-    etudids: list[int],
-    only_diplome=False,
-    date_commission=None,
-    date_jury=None,
-    numero_arrete=None,
-    code_vdi=None,
-    show_title=False,
-    pv_title=None,
-    anonymous=False,
-    with_paragraph_nom=False,
-) -> tuple[list, bool]:
-    """Objets platypus PDF récapitulant les décisions de jury
-    pour un type de jury (passage ou delivrance).
-    Ramene: liste d'onj platypus, et un boolen indiquant si au moins un étudiant est diplômé.
-    """
-    from app.scodoc import sco_pvjury
-    from app.but import jury_but_pv
-
-    a_diplome = False
-    # Jury de diplome si sem. terminal OU que l'on demande seulement les diplomés
-    diplome = formsemestre.est_terminal() or only_diplome
-    titre_jury, _ = _descr_jury(formsemestre, diplome)
-    titre_diplome = pv_title or formsemestre.formation.titre_officiel
-    objects = []
-
-    style, style_bullet = _make_pv_styles(formsemestre)
-
-    objects += [Spacer(0, 5 * mm)]
-    objects += sco_pdf.make_paras(
-        f"""
-    <para align="center"><b>Procès-verbal de {titre_jury} du département {
-        sco_preferences.get_preference("DeptName", formsemestre.id) or "(sans nom)"
-    } - Session unique {formsemestre.annee_scolaire()}</b></para>
-    """,
-        style,
-    )
-
-    objects += sco_pdf.make_paras(
-        f"""<para align="center"><b><i>{titre_diplome}</i></b></para>""",
-        style,
-    )
-
-    if show_title:
-        objects += sco_pdf.make_paras(
-            f"""<para align="center"><b>Semestre: {formsemestre.titre}</b></para>""",
-            style,
-        )
-    if sco_preferences.get_preference("PV_TITLE_WITH_VDI", formsemestre.id):
-        objects += sco_pdf.make_paras(
-            f"""<para align="center">VDI et Code: {(code_vdi or "")}</para>""", style
-        )
-
-    if date_jury:
-        objects += sco_pdf.make_paras(
-            f"""<para align="center">Jury tenu le {date_jury}</para>""", style
-        )
-
-    objects += sco_pdf.make_paras(
-        "<para>"
-        + (sco_preferences.get_preference("PV_INTRO", formsemestre.id) or "")
-        % {
-            "Decnum": numero_arrete,
-            "VDICode": code_vdi,
-            "UnivName": sco_preferences.get_preference("UnivName", formsemestre.id),
-            "Type": titre_jury,
-            "Date": date_commission,  # deprecated
-            "date_commission": date_commission,
-        }
-        + "</para>",
-        style_bullet,
-    )
-
-    objects += sco_pdf.make_paras(
-        """<para>Le jury propose les décisions suivantes :</para>""", style
-    )
-    objects += [Spacer(0, 4 * mm)]
-
-    if formsemestre.formation.is_apc():
-        rows, titles = jury_but_pv.pvjury_table_but(
-            formsemestre, etudids=etudids, line_sep="<br/>"
-        )
-        columns_ids = list(titles.keys())
-        a_diplome = codes_cursus.ADM in [row.get("diplome") for row in rows]
-    else:
-        dpv = sco_dict_pv_jury.dict_pvjury(
-            formsemestre.id, etudids=etudids, with_prev=True
-        )
-        if not dpv:
-            return [], False
-        rows, titles, columns_ids = sco_pvjury.pvjury_table(
-            dpv,
-            only_diplome=only_diplome,
-            anonymous=anonymous,
-            with_paragraph_nom=with_paragraph_nom,
-        )
-        a_diplome = True in (x["validation_parcours"] for x in dpv["decisions"])
-    # convert to lists of tuples:
-    columns_ids = ["etudid"] + columns_ids
-    rows = [[line.get(x, "") for x in columns_ids] for line in rows]
-    titles = [titles.get(x, "") for x in columns_ids]
-    # Make a new cell style and put all cells in paragraphs
-    cell_style = styles.ParagraphStyle({})
-    cell_style.fontSize = sco_preferences.get_preference(
-        "SCOLAR_FONT_SIZE", formsemestre.id
-    )
-    cell_style.fontName = sco_preferences.get_preference("PV_FONTNAME", formsemestre.id)
-    cell_style.leading = 1.0 * sco_preferences.get_preference(
-        "SCOLAR_FONT_SIZE", formsemestre.id
-    )  # vertical space
-    LINEWIDTH = 0.5
-    table_style = [
-        (
-            "FONTNAME",
-            (0, 0),
-            (-1, 0),
-            sco_preferences.get_preference("PV_FONTNAME", formsemestre.id),
-        ),
-        ("LINEBELOW", (0, 0), (-1, 0), LINEWIDTH, Color(0, 0, 0)),
-        ("GRID", (0, 0), (-1, -1), LINEWIDTH, Color(0, 0, 0)),
-        ("VALIGN", (0, 0), (-1, -1), "TOP"),
-    ]
-    titles = [f"<para><b>{x}</b></para>" for x in titles]
-
-    def _format_pv_cell(x):
-        """convert string to paragraph"""
-        if isinstance(x, str):
-            return Paragraph(SU(x), cell_style)
-        else:
-            return x
-
-    widths_by_id = {
-        "nom": 5 * cm,
-        "cursus": 2.8 * cm,
-        "ects": 1.4 * cm,
-        "devenir": 1.8 * cm,
-        "decision_but": 1.8 * cm,
-    }
-
-    table_cells = [[_format_pv_cell(x) for x in line[1:]] for line in ([titles] + rows)]
-    widths = [widths_by_id.get(col_id) for col_id in columns_ids[1:]]
-
-    objects.append(
-        Table(table_cells, repeatRows=1, colWidths=widths, style=table_style)
-    )
-
-    # Signature du directeur
-    objects += sco_pdf.make_paras(
-        f"""<para spaceBefore="10mm" align="right">{
-            sco_preferences.get_preference("DirectorName", formsemestre.id) or ""
-        }, {
-            sco_preferences.get_preference("DirectorTitle", formsemestre.id) or ""
-        }</para>""",
-        style,
-    )
-
-    # Légende des codes
-    codes = list(codes_cursus.CODES_EXPL.keys())
-    codes.sort()
-    objects += sco_pdf.make_paras(
-        """<para spaceBefore="15mm" fontSize="14">
-    <b>Codes utilisés :</b></para>""",
-        style,
-    )
-    L = []
-    for code in codes:
-        L.append((code, codes_cursus.CODES_EXPL[code]))
-    TableStyle2 = [
-        (
-            "FONTNAME",
-            (0, 0),
-            (-1, 0),
-            sco_preferences.get_preference("PV_FONTNAME", formsemestre.id),
-        ),
-        ("LINEBELOW", (0, 0), (-1, -1), LINEWIDTH, Color(0, 0, 0)),
-        ("LINEABOVE", (0, 0), (-1, -1), LINEWIDTH, Color(0, 0, 0)),
-        ("LINEBEFORE", (0, 0), (0, -1), LINEWIDTH, Color(0, 0, 0)),
-        ("LINEAFTER", (-1, 0), (-1, -1), LINEWIDTH, Color(0, 0, 0)),
-    ]
-    objects.append(
-        Table(
-            [[Paragraph(SU(x), cell_style) for x in line] for line in L],
-            colWidths=(2 * cm, None),
-            style=TableStyle2,
-        )
-    )
-
-    return objects, a_diplome
diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py
index 4ba47dc81..7e2e7cfde 100644
--- a/app/scodoc/sco_report.py
+++ b/app/scodoc/sco_report.py
@@ -54,7 +54,6 @@ from app.scodoc import sco_etud
 from app.scodoc import sco_formsemestre
 from app.scodoc import sco_formsemestre_inscriptions
 from app.scodoc import sco_preferences
-from app.scodoc import sco_pvjury
 import sco_version
 from app.scodoc.gen_tables import GenTable
 from app import log
diff --git a/app/templates/about.j2 b/app/templates/about.j2
index 3dff1edee..fa322e44e 100644
--- a/app/templates/about.j2
+++ b/app/templates/about.j2
@@ -15,6 +15,19 @@
     Information et documentation sur <a href="https://scodoc.org" target="_blank">scodoc.org</a>.
 </p>
 
+<p>Le logiciel est distribué sous
+    <a href="https://www.gnu.org/licenses/old-licenses/gpl-2.0.html">licence GNU
+        GPL v2</a>. <em>ScoDoc est un logiciel réalisé dans l'espoir d'être utile
+        mais distribué "en l'état" sans aucune garantie de quelque nature que ce
+        soit, expresse ou ou implicite, y compris, mais sans y être limité, les
+        garanties implicites de commerciabilité et de la conformité a une
+        utilisation particulière. Vous assumez la totalité des risques liés à la
+        qualité et aux performances du programme. Si le programme se révélait
+        défectueux, le coût de l'entretien, des réparations ou des corrections
+        nécessaires vous incombent intégralement.
+    </em>
+</p>
+
 <h2>Dernières évolutions</h2>
 
 {{ news|safe }}
diff --git a/app/views/notes.py b/app/views/notes.py
index 85a53dc60..429adf387 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -122,12 +122,11 @@ from app.scodoc import sco_lycee
 from app.scodoc import sco_moduleimpl
 from app.scodoc import sco_moduleimpl_inscriptions
 from app.scodoc import sco_moduleimpl_status
-from app.scodoc import sco_permissions_check
 from app.scodoc import sco_placement
 from app.scodoc import sco_poursuite_dut
 from app.scodoc import sco_preferences
 from app.scodoc import sco_prepajury
-from app.scodoc import sco_pvjury
+from app.scodoc import sco_pv_forms
 from app.scodoc import sco_recapcomplet
 from app.scodoc import sco_report
 from app.scodoc import sco_report_but
@@ -2803,7 +2802,9 @@ def formsemestre_validation_suppress_etud(
 
 
 # ------------- PV de JURY et archives
-sco_publish("/formsemestre_pvjury", sco_pvjury.formsemestre_pvjury, Permission.ScoView)
+sco_publish(
+    "/formsemestre_pvjury", sco_pv_forms.formsemestre_pvjury, Permission.ScoView
+)
 
 sco_publish("/pvjury_page_but", jury_but_pv.pvjury_page_but, Permission.ScoView)
 
@@ -2913,12 +2914,12 @@ def formsemestre_jury_but_erase(
 
 sco_publish(
     "/formsemestre_lettres_individuelles",
-    sco_pvjury.formsemestre_lettres_individuelles,
+    sco_pv_forms.formsemestre_lettres_individuelles,
     Permission.ScoView,
     methods=["GET", "POST"],
 )
 sco_publish(
-    "/formsemestre_pvjury_pdf", sco_pvjury.formsemestre_pvjury_pdf, Permission.ScoView
+    "/formsemestre_pvjury_pdf", sco_pv_forms.formsemestre_pvjury_pdf, Permission.ScoView
 )
 sco_publish(
     "/feuille_preparation_jury",
diff --git a/sco_version.py b/sco_version.py
index 0442f9c27..98786ca06 100644
--- a/sco_version.py
+++ b/sco_version.py
@@ -6,6 +6,15 @@ SCOVERSION = "9.4.45"
 SCONAME = "ScoDoc"
 
 SCONEWS = """
+<h4>Année 2023</h4>
+<ul>
+<li>ScoDoc 9.4</li>
+<ul>
+    <li>Améliorations des tableaux récapitulatifs</li>
+    <li>Nouvelle interface de gestions des groupes (S. Lehmann)</li>
+    <li>Enrichissement des jurys BUT et des procès-verbaux associés.</li>
+ </ul>
+</ul>
 <h4>Année 2022</h4>
 <ul>
 <li>ScoDoc 9.4</li>
@@ -14,7 +23,7 @@ SCONEWS = """
  </ul>
 <li>ScoDoc 9.3</li>
 <ul>
- <li>Nouvelle API REST pour connecter ScoDoc à d'autres applications<li>
+ <li>Nouvelle API REST pour connecter ScoDoc à d'autres applications</li>
  <li>Module de gestion des relations avec les entreprises</li>
  <li>Prise en charge des parcours BUT</li>
  <li>Association des UEs aux compétences du référentiel</li>
diff --git a/tests/unit/yaml_setup_but.py b/tests/unit/yaml_setup_but.py
index 516fd7358..864a2667e 100644
--- a/tests/unit/yaml_setup_but.py
+++ b/tests/unit/yaml_setup_but.py
@@ -29,7 +29,7 @@ from app.models import (
     UniteEns,
 )
 from app.scodoc import sco_utils as scu
-from app.scodoc import sco_dict_pv_jury
+from app.scodoc import sco_pv_dict
 
 
 def setup_formation_referentiel(formation: Formation, refcomp_infos: dict):
@@ -308,7 +308,7 @@ def but_test_jury(formsemestre: FormSemestre, doc: dict):
                 but_compare_decisions_annee(deca, deca_att)
             if "autorisations_inscription" in doc_formsemestre["attendu"]:
                 if dpv is None:  # lazy load
-                    dpv = sco_dict_pv_jury.dict_pvjury(formsemestre.id)
+                    dpv = sco_pv_dict.dict_pvjury(formsemestre.id)
                 check_autorisations_inscription(
                     etud, dpv, doc_formsemestre["attendu"]["autorisations_inscription"]
                 )
-- 
GitLab