From ebd71a32994ee3b9dd3ab3ff975df75785c8cdee Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Sat, 24 Aug 2024 14:39:19 +0200
Subject: [PATCH] Templatification des vues (WIP)

---
 app/scodoc/sco_placement.py                 |  18 ++--
 app/scodoc/sco_report.py                    |  64 ++++++------
 app/scodoc/sco_report_but.py                |  11 +--
 app/scodoc/sco_semset.py                    |  33 +++----
 app/scodoc/sco_trombino.py                  |  99 ++++++++++---------
 app/static/css/scodoc.css                   |   4 +-
 app/static/js/trombino.js                   |  10 --
 app/templates/auth/toogle_active_user.j2    |   2 +-
 app/templates/scodoc/forms/placement.j2     |   9 ++
 app/templates/scolar/photos_import_files.j2 |   2 +-
 app/views/absences.py                       |  78 +++++++--------
 app/views/assiduites.py                     |  26 +++--
 app/views/jury_validations.py               |  45 +++++----
 app/views/notes.py                          |  41 ++++----
 app/views/users.py                          | 102 ++++----------------
 15 files changed, 237 insertions(+), 307 deletions(-)
 delete mode 100644 app/static/js/trombino.js

diff --git a/app/scodoc/sco_placement.py b/app/scodoc/sco_placement.py
index c019bf1dd..1b51ced2b 100644
--- a/app/scodoc/sco_placement.py
+++ b/app/scodoc/sco_placement.py
@@ -51,7 +51,7 @@ from wtforms import (
 from app.models import Evaluation, ModuleImpl
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
-from app.scodoc import html_sco_header, sco_preferences
+from app.scodoc import sco_preferences
 from app.scodoc import sco_edit_module
 from app.scodoc import sco_evaluations
 from app.scodoc import sco_excel
@@ -204,14 +204,14 @@ def placement_eval_selectetuds(evaluation_id):
                 % runner.__dict__
             )
         return runner.exec_placement()  # calcul et generation du fichier
-    htmls = [
-        html_sco_header.sco_header(),
-        sco_evaluations.evaluation_describe(evaluation_id=evaluation_id),
-        "<h3>Placement et émargement des étudiants</h3>",
-        render_template("scodoc/forms/placement.j2", form=form),
-    ]
-    footer = html_sco_header.sco_footer()
-    return "\n".join(htmls) + "<p>" + footer
+
+    return render_template(
+        "scodoc/forms/placement.j2",
+        evaluations_description=sco_evaluations.evaluation_describe(
+            evaluation_id=evaluation_id
+        ),
+        form=form,
+    )
 
 
 class PlacementRunner:
diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py
index 2f316bd6c..0984c8c3a 100644
--- a/app/scodoc/sco_report.py
+++ b/app/scodoc/sco_report.py
@@ -37,7 +37,7 @@ import time
 import datetime
 from operator import itemgetter
 
-from flask import url_for, g, request
+from flask import url_for, g, render_template, request
 import pydot
 
 from app import log
@@ -50,7 +50,6 @@ from app.models.etudiants import Identite
 
 from app.scodoc import (
     codes_cursus,
-    html_sco_header,
     sco_etud,
     sco_formsemestre,
     sco_formsemestre_inscriptions,
@@ -411,11 +410,6 @@ def formsemestre_report_counts(
     if fmt != "html":
         return tableau
     H = [
-        html_sco_header.sco_header(
-            cssstyles=sco_groups_view.CSSSTYLES,
-            javascripts=sco_groups_view.JAVASCRIPTS,
-            page_title=title,
-        ),
         tableau,
         "\n".join(F),
         """<p class="help">Le tableau affiche le nombre d'étudiants de ce semestre dans chacun
@@ -423,9 +417,14 @@ def formsemestre_report_counts(
           pour les lignes et les colonnes. Le <tt>codedecision</tt> est le code de la décision
           du jury.
           </p>""",
-        html_sco_header.sco_footer(),
     ]
-    return "\n".join(H)
+    return render_template(
+        "sco_page.j2",
+        cssstyles=sco_groups_view.CSSSTYLES,
+        javascripts=sco_groups_view.JAVASCRIPTS,
+        title=title,
+        content="\n".join(H),
+    )
 
 
 # --------------------------------------------------------------------------
@@ -813,11 +812,6 @@ def formsemestre_suivi_cohorte(
             href="{burl}&percent=1">Afficher les résultats en pourcentages</a></p>"""
 
     H = [
-        html_sco_header.sco_header(
-            cssstyles=sco_groups_view.CSSSTYLES,
-            javascripts=sco_groups_view.JAVASCRIPTS,
-            page_title=tab.page_title,
-        ),
         """<h2 class="formsemestre">Suivi cohorte: devenir des étudiants de ce semestre</h2>""",
         _gen_form_selectetuds(
             formsemestre.id,
@@ -853,9 +847,14 @@ def formsemestre_suivi_cohorte(
     </p>
     """,
         expl,
-        html_sco_header.sco_footer(),
     ]
-    return "\n".join(H)
+    return render_template(
+        "sco_page.j2",
+        cssstyles=sco_groups_view.CSSSTYLES,
+        javascripts=sco_groups_view.JAVASCRIPTS,
+        title=tab.page_title,
+        content="\n".join(H),
+    )
 
 
 def _gen_form_selectetuds(
@@ -1365,15 +1364,11 @@ def formsemestre_suivi_cursus(
     ]
 
     H = [
-        html_sco_header.sco_header(
-            page_title=tab.page_title,
-        ),
         """<h2 class="formsemestre">Cursus suivis par les étudiants de ce semestre</h2>""",
         "\n".join(F),
         t,
-        html_sco_header.sco_footer(),
     ]
-    return "\n".join(H)
+    return render_template("sco_page.j2", title=tab.page_title, content="\n".join(H))
 
 
 # -------------
@@ -1742,12 +1737,6 @@ def formsemestre_graph_cursus(
         )
 
         H = [
-            html_sco_header.sco_header(
-                cssstyles=sco_groups_view.CSSSTYLES,
-                javascripts=sco_groups_view.JAVASCRIPTS,
-                page_title="Graphe cursus de %(titreannee)s" % sem,
-                no_sidebar=True,
-            ),
             """<h2 class="formsemestre">Cursus des étudiants de ce semestre</h2>""",
             doc,
             f"<p>{len(etuds)} étudiants sélectionnés</p>",
@@ -1771,11 +1760,12 @@ def formsemestre_graph_cursus(
             ),
             """<p>Origine et devenir des étudiants inscrits dans %(titreannee)s"""
             % sem,
-            """(<a href="%s">version pdf</a>"""
-            % url_for("notes.formsemestre_graph_cursus", fmt="pdf", **url_kw),
-            """, <a href="%s">image PNG</a>)"""
-            % url_for("notes.formsemestre_graph_cursus", fmt="png", **url_kw),
-            f"""
+            f"""(<a href="{
+                url_for("notes.formsemestre_graph_cursus", fmt="pdf", **url_kw)
+                }">version pdf</a>,
+                <a href="{
+                    url_for("notes.formsemestre_graph_cursus", fmt="png", **url_kw)}">image PNG</a>)
+
             </p>
             <p class="help">Le graphe permet de suivre les étudiants inscrits dans le semestre
               sélectionné (dessiné en vert). Chaque rectangle représente un semestre (cliquez dedans
@@ -1788,8 +1778,14 @@ def formsemestre_graph_cursus(
             étudiants appartenant aux groupes indiqués <em>dans le semestre d'origine</em>.
             </p>
             """,
-            html_sco_header.sco_footer(),
         ]
-        return "\n".join(H)
+        return render_template(
+            "sco_page.j2",
+            cssstyles=sco_groups_view.CSSSTYLES,
+            javascripts=sco_groups_view.JAVASCRIPTS,
+            page_title=f"Graphe cursus de {sem['titreannee']}",
+            no_sidebar=True,
+            content="\n".join(H),
+        )
     else:
         raise ValueError(f"invalid format: {fmt}")
diff --git a/app/scodoc/sco_report_but.py b/app/scodoc/sco_report_but.py
index 56ffe6d31..8a685a118 100644
--- a/app/scodoc/sco_report_but.py
+++ b/app/scodoc/sco_report_but.py
@@ -31,21 +31,18 @@
 from collections import defaultdict
 
 
-from flask import request
+from flask import render_template, request
 
 from app import db
 from app.but import jury_but
 from app.models import FormSemestre
 from app.models.formsemestre import FormSemestreInscription
-
 import app.scodoc.sco_utils as scu
-from app.scodoc import html_sco_header
 from app.scodoc import codes_cursus
 from app.scodoc.sco_exceptions import ScoValueError
-
 from app.scodoc import sco_preferences
-import sco_version
 from app.scodoc.gen_tables import GenTable
+import sco_version
 
 
 # Titres, ordonnés
@@ -107,13 +104,11 @@ def formsemestre_but_indicateurs(formsemestre_id: int, fmt="html"):
     if fmt != "html":
         return t
     H = [
-        html_sco_header.sco_header(page_title=title),
         t,
         """<p class="help">
           </p>""",
-        html_sco_header.sco_footer(),
     ]
-    return "\n".join(H)
+    return render_template("sco_page.j2", title=title, content="\n".join(H))
 
 
 def but_indicateurs_by_bac(formsemestre: FormSemestre) -> dict[str:dict]:
diff --git a/app/scodoc/sco_semset.py b/app/scodoc/sco_semset.py
index 2eb4c4b39..a1d6cb26e 100644
--- a/app/scodoc/sco_semset.py
+++ b/app/scodoc/sco_semset.py
@@ -40,17 +40,15 @@ sem_set_list()
 """
 
 import flask
-from flask import g, url_for
+from flask import g, render_template, url_for
 
 from app import db, log
 from app.comp import res_sem
 from app.comp.res_compat import NotesTableCompat
 from app.models import FormSemestre
-from app.scodoc import html_sco_header
 from app.scodoc import sco_etape_apogee
 from app.scodoc import sco_formsemestre
 from app.scodoc import sco_formsemestre_status
-from app.scodoc import sco_portal_apogee
 from app.scodoc import sco_preferences
 from app.scodoc.gen_tables import GenTable
 from app.scodoc.sco_etape_bilan import EtapeBilan
@@ -412,7 +410,7 @@ def do_semset_delete(semset_id, dialog_confirmed=False):
     s = SemSet(semset_id=semset_id)
     if not dialog_confirmed:
         return scu.confirm_dialog(
-            "<h2>Suppression de l'ensemble %(title)s ?</h2>" % s,
+            f"<h2>Suppression de l'ensemble {s['title']} ?</h2>",
             dest_url="",
             parameters={"semset_id": semset_id},
             cancel_url="semset_page",
@@ -421,14 +419,14 @@ def do_semset_delete(semset_id, dialog_confirmed=False):
     return flask.redirect("semset_page")
 
 
-def edit_semset_set_title(id=None, value=None):
+def edit_semset_set_title(oid=None, value=None):
     """Change title of semset"""
     title = value.strip()
-    if not id:
+    if not oid:
         raise ScoValueError("empty semset_id")
-    SemSet(semset_id=id)
+    SemSet(semset_id=oid)
     cnx = ndb.GetDBConnexion()
-    semset_edit(cnx, {"semset_id": id, "title": title})
+    semset_edit(cnx, {"semset_id": oid, "title": title})
     return title
 
 
@@ -517,22 +515,18 @@ def semset_page(fmt="html"):
 
     page_title = "Ensembles de semestres"
     H = [
-        html_sco_header.sco_header(
-            page_title=page_title,
-            javascripts=["libjs/jinplace-1.2.1.min.js"],
-        ),
         """<script>$(function() {
            $('.inplace_edit').jinplace();
            });
            </script>""",
-        "<h2>%s</h2>" % page_title,
+        f"<h2>{page_title}</h2>",
     ]
     H.append(tab.html())
 
     annee_courante = int(scu.annee_scolaire())
     menu_annee = "\n".join(
         [
-            '<option value="%s">%s</option>' % (i, i)
+            f"""<option value="{i}">{i}</option>"""
             for i in range(2014, annee_courante + 1)
         ]
     )
@@ -561,8 +555,8 @@ def semset_page(fmt="html"):
 
     H.append(
         """
-    <div>
-    <h4>Autres opérations:</h4>
+    <div class="scobox space-before-24">
+    <div class="scobox-title">Autres opérations :</div>
     <ul>
     <li><a class="stdlink" href="scodoc_table_results">
     Table des résultats de tous les semestres
@@ -575,4 +569,9 @@ def semset_page(fmt="html"):
     """
     )
 
-    return "\n".join(H) + html_sco_header.sco_footer()
+    return render_template(
+        "sco_page_dept.j2",
+        title=page_title,
+        javascripts=["libjs/jinplace-1.2.1.min.js"],
+        content="\n".join(H),
+    )
diff --git a/app/scodoc/sco_trombino.py b/app/scodoc/sco_trombino.py
index 3ed60ec12..245ba85b4 100644
--- a/app/scodoc/sco_trombino.py
+++ b/app/scodoc/sco_trombino.py
@@ -49,7 +49,6 @@ import app.scodoc.sco_utils as scu
 from app.scodoc.TrivialFormulator import TrivialFormulator
 from app.scodoc.sco_exceptions import ScoPDFFormatError, ScoValueError
 from app.scodoc.sco_pdf import SU
-from app.scodoc import html_sco_header
 from app.scodoc import htmlutils
 from app.scodoc import sco_import_etuds
 from app.scodoc import sco_excel
@@ -90,12 +89,7 @@ def trombino(
         return _listeappel_photos_pdf(groups_infos)
     elif fmt == "doc":
         return sco_trombino_doc.trombino_doc(groups_infos)
-    else:
-        raise Exception("invalid format")
-
-
-def _trombino_html_header():
-    return html_sco_header.sco_header(javascripts=["js/trombino.js"])
+    raise ValueError("invalid format")
 
 
 def trombino_html(groups_infos):
@@ -251,14 +245,18 @@ def trombino_copy_photos(group_ids=None, dialog_confirmed=False):
     back_url = "groups_photos?" + str(groups_infos.groups_query_args)
 
     portal_url = sco_portal_apogee.get_portal_url()
-    header = html_sco_header.sco_header(page_title="Chargement des photos")
-    footer = html_sco_header.sco_footer()
     if not portal_url:
-        return f"""{ header }
+        return render_template(
+            "sco_page.j2",
+            content=f"""
             <p>portail non configuré</p>
-            <p><a href="{back_url}" class="stdlink">Retour au trombinoscope</a></p>
-            { footer }
-        """
+            <div>
+                <a class="stdlink" href="{back_url}" class="stdlink">
+                Retour au trombinoscope
+                </a>
+            </div>
+        """,
+        )
     if not dialog_confirmed:
         return scu.confirm_dialog(
             f"""<h2>Copier les photos du portail vers ScoDoc ?</h2>
@@ -285,14 +283,18 @@ def trombino_copy_photos(group_ids=None, dialog_confirmed=False):
 
     msg.append(f"<b>{nok} photos correctement chargées</b>")
 
-    return f"""{ header }
+    return render_template(
+        "sco_page.j2",
+        content=f"""
         <h2>Chargement des photos depuis le portail</h2>
         <ul><li>
         { '</li><li>'.join(msg) }
         </li></ul>
-        <p><a href="{back_url}">retour au trombinoscope</a>
-        { footer }
-        """
+        <div class="space-before-24">
+            <a class="stdlink" href="{back_url}">retour au trombinoscope</a>
+        </div>
+        """,
+    )
 
 
 def _get_etud_platypus_image(t, image_width=2 * cm):
@@ -506,7 +508,6 @@ def photos_import_files_form(group_ids=()):
     back_url = f"groups_photos?{groups_infos.groups_query_args}"
 
     H = [
-        html_sco_header.sco_header(page_title="Import des photos des étudiants"),
         f"""<h2 class="formsemestre">Téléchargement des photos des étudiants</h2>
          <p><b>Vous pouvez aussi charger les photos individuellement via la fiche
          de chaque étudiant (menu "Étudiant" / "Changer la photo").</b>
@@ -526,7 +527,6 @@ def photos_import_files_form(group_ids=()):
         <li style="padding-top: 2em;">
          """,
     ]
-    F = html_sco_header.sco_footer()
     vals = scu.get_request_args()
     vals["group_ids"] = groups_infos.group_ids
     tf = TrivialFormulator(
@@ -540,37 +540,40 @@ def photos_import_files_form(group_ids=()):
     )
 
     if tf[0] == 0:
-        return "\n".join(H) + tf[1] + "</li></ol>" + F
-    elif tf[0] == -1:
-        return flask.redirect(back_url)
-    else:
-
-        def callback(etud: Identite, data, filename):
-            return sco_photos.store_photo(etud, data, filename)
-
-        (
-            ignored_zipfiles,
-            unmatched_files,
-            stored_etud_filename,
-        ) = zip_excel_import_files(
-            xlsfile=tf[2]["xlsfile"],
-            zipfile=tf[2]["zipfile"],
-            callback=callback,
-            filename_title="fichier_photo",
-            back_url=back_url,
-        )
         return render_template(
-            "scolar/photos_import_files.j2",
-            page_title="Téléchargement des photos des étudiants",
-            ignored_zipfiles=ignored_zipfiles,
-            unmatched_files=unmatched_files,
-            stored_etud_filename=stored_etud_filename,
-            next_page=url_for(
-                "scolar.groups_photos",
-                scodoc_dept=g.scodoc_dept,
-                formsemestre_id=groups_infos.formsemestre_id,
-            ),
+            "sco_page.j2",
+            title="Import des photos des étudiants",
+            content="\n".join(H) + tf[1] + "</li></ol>",
         )
+    if tf[0] == -1:
+        return flask.redirect(back_url)
+
+    def callback(etud: Identite, data, filename):
+        return sco_photos.store_photo(etud, data, filename)
+
+    (
+        ignored_zipfiles,
+        unmatched_files,
+        stored_etud_filename,
+    ) = zip_excel_import_files(
+        xlsfile=tf[2]["xlsfile"],
+        zipfile=tf[2]["zipfile"],
+        callback=callback,
+        filename_title="fichier_photo",
+        back_url=back_url,
+    )
+    return render_template(
+        "scolar/photos_import_files.j2",
+        page_title="Téléchargement des photos des étudiants",
+        ignored_zipfiles=ignored_zipfiles,
+        unmatched_files=unmatched_files,
+        stored_etud_filename=stored_etud_filename,
+        next_page=url_for(
+            "scolar.groups_photos",
+            scodoc_dept=g.scodoc_dept,
+            formsemestre_id=groups_infos.formsemestre_id,
+        ),
+    )
 
 
 def _norm_zip_filename(fn, lowercase=True):
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index cbfba50e9..e54fa801e 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -59,11 +59,11 @@ div.sco-app-content {
 }
 
 .space-before-18 {
-  margin-top: 18px;
+  margin-top: 18px !important;
 }
 
 .space-before-24 {
-  margin-top: 24px;
+  margin-top: 24px !important;
 }
 
 div.scobox.maxwidth {
diff --git a/app/static/js/trombino.js b/app/static/js/trombino.js
deleted file mode 100644
index a410fc271..000000000
--- a/app/static/js/trombino.js
+++ /dev/null
@@ -1,10 +0,0 @@
-// Affichage progressif du trombinoscope html
-
-$().ready(function () {
-  var spans = $(".unloaded_img");
-  for (var i = 0; i < spans.size(); i++) {
-    var sp = spans[i];
-    var etudid = sp.id;
-    $(sp).load(SCO_URL + "etud_photo_html?etudid=" + etudid);
-  }
-});
diff --git a/app/templates/auth/toogle_active_user.j2 b/app/templates/auth/toogle_active_user.j2
index 3cc4c2844..8c420447f 100644
--- a/app/templates/auth/toogle_active_user.j2
+++ b/app/templates/auth/toogle_active_user.j2
@@ -10,7 +10,7 @@
     <br />
     Ces utilisateurs peuvent être réactivés à tout moment.
 </div>
-<div class="row">
+<div class="row space-before-24">
     <div class="col-md-4">
         {{ wtf.quick_form(form, button_map={'submit':'secondary'}) }}
     </div>
diff --git a/app/templates/scodoc/forms/placement.j2 b/app/templates/scodoc/forms/placement.j2
index 7cb5b482e..ecfe36649 100644
--- a/app/templates/scodoc/forms/placement.j2
+++ b/app/templates/scodoc/forms/placement.j2
@@ -1,3 +1,5 @@
+{% extends 'sco_page.j2' %}
+
 {% import 'wtf.j2' as wtf %}
 
 {% macro render_field(field) %}
@@ -15,6 +17,12 @@
 </tr>
 {% endmacro %}
 
+{% block app_content %}
+
+{{ evaluations_description|safe}}
+
+<h3 class="space-before-24">Placement et émargement des étudiants</h3>
+
 <div class="saisienote_etape1 form_placement">
     <form method=post>
         {{ form.evaluation_id }}
@@ -78,3 +86,4 @@
         </li>
     </ul>
 </div>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/scolar/photos_import_files.j2 b/app/templates/scolar/photos_import_files.j2
index 2961d7d73..b139782f2 100644
--- a/app/templates/scolar/photos_import_files.j2
+++ b/app/templates/scolar/photos_import_files.j2
@@ -1,5 +1,5 @@
 {# -*- mode: jinja-html -*- #}
-{% extends 'base.j2' %}
+{% extends 'sco_page.j2' %}
 
 {% block app_content %}
 
diff --git a/app/views/absences.py b/app/views/absences.py
index d3f26ebbd..e539a0731 100644
--- a/app/views/absences.py
+++ b/app/views/absences.py
@@ -24,7 +24,8 @@
 ##############################################################################
 
 """
-Module absences: remplacé par assiduité en août 2023, reste ici seulement la gestion des "billets"
+Module absences: remplacé par assiduité en août 2023,
+reste ici seulement la gestion des "billets"
 
 """
 
@@ -32,8 +33,7 @@ import dateutil
 import dateutil.parser
 
 import flask
-from flask import g, request
-from flask import abort, flash, url_for
+from flask import abort, flash, g, render_template, request, url_for
 from flask_login import current_user
 
 from app import db, log
@@ -54,11 +54,8 @@ from app.scodoc import sco_utils as scu
 from app.scodoc.sco_permissions import Permission
 from app.scodoc.sco_exceptions import ScoValueError
 from app.scodoc.TrivialFormulator import TrivialFormulator
-from app.scodoc import html_sco_header
-from app.scodoc import sco_cal
 from app.scodoc import sco_assiduites as scass
 from app.scodoc import sco_abs_billets
-from app.scodoc import sco_etud
 from app.scodoc import sco_preferences
 
 
@@ -77,11 +74,7 @@ from app.scodoc import sco_preferences
 def index_html():
     """Gestionnaire absences, page principale"""
 
-    H = [
-        html_sco_header.sco_header(
-            page_title="Billets d'absences",
-        ),
-    ]
+    H = []
     if current_user.has_permission(
         Permission.AbsChange
     ) and sco_preferences.get_preference("handle_billets_abs"):
@@ -93,8 +86,9 @@ def index_html():
             </li></ul>
             """
         )
-    H.append(html_sco_header.sco_footer())
-    return "\n".join(H)
+    return render_template(
+        "sco_page_dept.j2", title="Billets d'absences", content="\n".join(H)
+    )
 
 
 # ----- Gestion des "billets d'absence": signalement par les etudiants eux mêmes (à travers le portail)
@@ -158,12 +152,8 @@ def add_billets_absence_form(etudid):
     """Formulaire ajout billet (pour tests seulement, le vrai
     formulaire accessible aux etudiants étant sur le portail étudiant).
     """
-    etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0]
-    H = [
-        html_sco_header.sco_header(
-            page_title="Billet d'absence de %s" % etud["nomprenom"]
-        )
-    ]
+    _ = Identite.get_etud(etudid)  # check
+    H = ["""<h2>Formulaire ajout billet (pour tests)</h2>"""]
     tf = TrivialFormulator(
         request.base_url,
         scu.get_request_args(),
@@ -179,7 +169,11 @@ def add_billets_absence_form(etudid):
         ),
     )
     if tf[0] == 0:
-        return "\n".join(H) + tf[1] + html_sco_header.sco_footer()
+        return render_template(
+            "sco_page_dept.j2",
+            title="""Billet d'absence de {etud["nomprenom"]}""",
+            content="\n".join(H) + tf[1],
+        )
     elif tf[0] == -1:
         return flask.redirect(url_for("scolar.index_html", scodoc_dept=g.scodoc_dept))
     else:
@@ -242,11 +236,8 @@ def list_billets():
     et formulaire recherche d'un billet.
     """
     table = sco_abs_billets.table_billets_etud(etat=False)
-    T = table.html()
+    table_html = table.html()
     H = [
-        html_sco_header.sco_header(
-            page_title="Billet d'absence non traités",
-        ),
         f"<h2>Billets d'absence en attente de traitement ({table.get_nb_rows()})</h2>",
     ]
 
@@ -257,15 +248,18 @@ def list_billets():
         submitbutton=False,
     )
     if tf[0] == 0:
-        return "\n".join(H) + tf[1] + T + html_sco_header.sco_footer()
-    else:
-        return flask.redirect(
-            url_for(
-                "absences.process_billet_absence_form",
-                billet_id=tf[2]["billet_id"],
-                scodoc_dept=g.scodoc_dept,
-            )
+        return render_template(
+            "sco_page.j2",
+            title="Billet d'absence non traités",
+            content="\n".join(H) + tf[1] + table_html,
+        )
+    return flask.redirect(
+        url_for(
+            "absences.process_billet_absence_form",
+            billet_id=tf[2]["billet_id"],
+            scodoc_dept=g.scodoc_dept,
         )
+    )
 
 
 @bp.route("/delete_billets_absence", methods=["POST", "GET"])
@@ -337,7 +331,8 @@ def _ProcessBilletAbsence(
 def process_billet_absence_form(billet_id: int):
     """Formulaire traitement d'un billet"""
     if not isinstance(billet_id, int):
-        raise abort(404, "billet_id invalide")
+        abort(404, "billet_id invalide")
+        return  # safety guard
     billet: BilletAbsence = (
         BilletAbsence.query.filter_by(id=billet_id)
         .join(Identite)
@@ -352,9 +347,6 @@ def process_billet_absence_form(billet_id: int):
     etud = billet.etudiant
 
     H = [
-        html_sco_header.sco_header(
-            page_title=f"Traitement billet d'absence de {etud.nomprenom}",
-        ),
         f"""<h2>Traitement du billet {billet.id} : <a class="discretelink" href="{
             url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
             }">{etud.nomprenom}</a></h2>
@@ -403,7 +395,11 @@ def process_billet_absence_form(billet_id: int):
             </p>
             """
 
-        return "\n".join(H) + "<br>" + tf[1] + F + html_sco_header.sco_footer()
+        return render_template(
+            "sco_page.j2",
+            title=f"Traitement billet d'absence de {etud.nomprenom}",
+            content="\n".join(H) + "<br>" + tf[1] + F,
+        )
     elif tf[0] == -1:
         return flask.redirect(url_for("scolar.index_html", scodoc_dept=g.scodoc_dept))
     else:
@@ -414,7 +410,7 @@ def process_billet_absence_form(billet_id: int):
             j = "non justifiées"
         H.append('<div class="head_message">')
         if n > 0:
-            H.append("%d absences (1/2 journées) %s ajoutées" % (n, j))
+            H.append(f"{n} absences (1/2 journées) {j} ajoutées")
         elif n == 0:
             H.append("Aucun jour d'absence dans les dates indiquées !")
         elif n < 0:
@@ -434,4 +430,8 @@ def process_billet_absence_form(billet_id: int):
         )
         tab = sco_abs_billets.table_billets(billets, etud=etud)
         H.append(tab.html())
-        return "\n".join(H) + html_sco_header.sco_footer()
+        return render_template(
+            "sco_page.j2",
+            title=f"Traitement billet d'absence de {etud.nomprenom}",
+            content="\n".join(H),
+        )
diff --git a/app/views/assiduites.py b/app/views/assiduites.py
index 49b861a8a..43c4ddf8c 100644
--- a/app/views/assiduites.py
+++ b/app/views/assiduites.py
@@ -1073,10 +1073,10 @@ def signal_assiduites_group():
         select_all_when_unspecified=True,
     )
     if not groups_infos.members:
-        return (
-            html_sco_header.sco_header(page_title="Saisie de l'assiduité")
-            + "<h3>Aucun étudiant ! </h3>"
-            + html_sco_header.sco_footer()
+        return render_template(
+            "sco_page.j2",
+            title="Saisie de l'assiduité",
+            content="<h3>Aucun étudiant !</h3>",
         )
 
     # --- Filtrage par formsemestre ---
@@ -1952,10 +1952,10 @@ def signal_assiduites_hebdo():
         group_ids, formsemestre_id=formsemestre.id, select_all_when_unspecified=True
     )
     if not groups_infos.members:
-        return (
-            html_sco_header.sco_header(page_title="Assiduité: saisie hebdomadaire")
-            + "<h3>Aucun étudiant ! </h3>"
-            + html_sco_header.sco_footer()
+        return render_template(
+            "sco_page.j2",
+            title="Assiduité: feuille saisie hebdomadaire",
+            content="<h3>Aucun étudiant !</h3>",
         )
 
     # Récupération des étudiants
@@ -2305,12 +2305,10 @@ def feuille_abs_hebdo():
         group_ids, formsemestre_id=formsemestre.id, select_all_when_unspecified=True
     )
     if not groups_infos.members:
-        return (
-            html_sco_header.sco_header(
-                page_title="Assiduité: feuille saisie hebdomadaire"
-            )
-            + "<h3>Aucun étudiant ! </h3>"
-            + html_sco_header.sco_footer()
+        return render_template(
+            "sco_page.j2",
+            title="Assiduité: feuille saisie hebdomadaire",
+            content="<h3>Aucun étudiant !</h3>",
         )
 
     # Gestion des jours
diff --git a/app/views/jury_validations.py b/app/views/jury_validations.py
index d266040a6..656c4e8b9 100644
--- a/app/views/jury_validations.py
+++ b/app/views/jury_validations.py
@@ -60,7 +60,6 @@ from app.models import (
     ScoDocSiteConfig,
 )
 from app.scodoc import (
-    html_sco_header,
     sco_bulletins_json,
     sco_cache,
     sco_formsemestre_exterieurs,
@@ -251,27 +250,23 @@ def formsemestre_validation_but(
     </div>
     """
 
-    H = [
-        html_sco_header.sco_header(
-            page_title=f"Validation BUT S{formsemestre.semestre_id}",
-            formsemestre_id=formsemestre_id,
-            etudid=etudid,
+    H = ["""<div class="jury_but">"""]
+    inscription = formsemestre.etuds_inscriptions.get(etudid)
+    if not inscription:
+        raise ScoValueError("étudiant non inscrit au semestre")
+    if inscription.etat != scu.INSCRIT:
+        return render_template(
+            "sco_page.j2",
+            title=f"Validation BUT S{formsemestre.semestre_id}",
+            sco=ScoData(etud=etud, formsemestre=formsemestre),
             cssstyles=[
                 "css/jury_but.css",
                 "css/cursus_but.css",
             ],
             javascripts=("js/jury_but.js",),
-        ),
-        """<div class="jury_but">
-        """,
-    ]
-    inscription = formsemestre.etuds_inscriptions.get(etudid)
-    if not inscription:
-        raise ScoValueError("étudiant non inscrit au semestre")
-    if inscription.etat != scu.INSCRIT:
-        return (
-            "\n".join(H)
-            + f"""
+            content=(
+                "\n".join(H)
+                + f"""
             <div>
                 <div class="bull_head">
                 <div>
@@ -291,7 +286,7 @@ def formsemestre_validation_but(
             {navigation_div}
             </div>
         """
-            + html_sco_header.sco_footer()
+            ),
         )
 
     deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
@@ -341,7 +336,7 @@ def formsemestre_validation_but(
             êtes-vous certain de vouloir enregistrer une décision de jury&nbsp;?
             </div>"""
     if read_only:
-        warning += f"""<div class="warning">Affichage en lecture seule</div>"""
+        warning += """<div class="warning">Affichage en lecture seule</div>"""
 
     if deca.formsemestre_impair:
         inscription = deca.formsemestre_impair.etuds_inscriptions.get(etud.id)
@@ -507,7 +502,17 @@ def formsemestre_validation_but(
     </div>
     """
     )
-    return "\n".join(H) + html_sco_header.sco_footer()
+    return render_template(
+        "sco_page.j2",
+        title=f"Validation BUT S{formsemestre.semestre_id}",
+        sco=ScoData(etud=etud, formsemestre=formsemestre),
+        cssstyles=[
+            "css/jury_but.css",
+            "css/cursus_but.css",
+        ],
+        javascripts=("js/jury_but.js",),
+        content="\n".join(H),
+    )
 
 
 @bp.route(
diff --git a/app/views/notes.py b/app/views/notes.py
index e3fbdf4f7..3ed64a434 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -88,7 +88,6 @@ from app.scodoc.sco_exceptions import (
     ScoInvalidIdType,
 )
 from app.scodoc import (
-    html_sco_header,
     sco_apogee_compare,
     sco_archives_formsemestre,
     sco_assiduites,
@@ -770,8 +769,10 @@ def formation_import_xml_form():
         cancelbutton="Annuler",
     )
     if tf[0] == 0:
-        return f"""
-        { html_sco_header.sco_header(page_title="Import d'une formation") }
+        return render_template(
+            "sco_page_dept.j2",
+            title="Import d'une formation",
+            content=f"""
         <h2>Import d'une formation</h2>
         <p>Création d'une formation (avec UE, matières, modules)
             à partir un fichier XML (réservé aux utilisateurs avertis).
@@ -783,8 +784,8 @@ def formation_import_xml_form():
         }">page des référentiels</a>).
         </p>
         { tf[1] }
-        { html_sco_header.sco_footer() }
-        """
+        """,
+        )
     elif tf[0] == -1:
         return flask.redirect(url_for("notes.index_html", scodoc_dept=g.scodoc_dept))
     else:
@@ -792,8 +793,10 @@ def formation_import_xml_form():
             tf[2]["xmlfile"].read()
         )
 
-        return f"""
-        { html_sco_header.sco_header(page_title="Import d'une formation") }
+        return render_template(
+            "sco_page_dept.j2",
+            title="Import d'une formation",
+            content=f"""
         <h2>Import effectué !</h2>
         <ul>
             <li><a class="stdlink" href="{
@@ -806,8 +809,8 @@ def formation_import_xml_form():
             (en cas d'erreur, par exemple pour charger auparavant le référentiel de compétences)
             </li>
         </ul>
-        { html_sco_header.sco_footer() }
-        """
+        """,
+        )
 
 
 sco_publish("/module_move", sco_edit_formation.module_move, Permission.EditFormation)
@@ -2182,15 +2185,17 @@ def formsemestre_bulletins_mailetuds(
         if sent:
             nb_sent += 1
     #
-    return f"""
-    {html_sco_header.sco_header()}
+    return render_template(
+        "sco_page.j2",
+        title="Mailing bulletins",
+        content=f"""
     <p>{nb_sent} bulletins sur {len(inscriptions)} envoyés par mail !</p>
     <p><a class="stdlink" href="{url_for('notes.formsemestre_status',
                 scodoc_dept=g.scodoc_dept,
                 formsemestre_id=formsemestre_id)
         }">continuer</a></p>
-    {html_sco_header.sco_footer()}
-    """
+    """,
+    )
 
 
 sco_publish(
@@ -2258,10 +2263,8 @@ def appreciation_add_form(
     else:
         action = "Ajout"
     H = [
-        html_sco_header.sco_header(),
         f"""<h2>{action} d'une appréciation sur {etud.nomprenom}</h2>""",
     ]
-    F = html_sco_header.sco_footer()
     descr = [
         ("edit", {"input_type": "hidden", "default": edit}),
         ("etudid", {"input_type": "hidden"}),
@@ -2286,7 +2289,7 @@ def appreciation_add_form(
         submitlabel="Ajouter appréciation",
     )
     if tf[0] == 0:
-        return "\n".join(H) + "\n" + tf[1] + F
+        return render_template("sco_page.j2", content="\n".join(H) + "\n" + tf[1])
     elif tf[0] == -1:
         return flask.redirect(bul_url)
     else:
@@ -2643,7 +2646,7 @@ def check_form_integrity(formation_id, fix=False):
     else:
         txth = "OK"
         log("ok")
-    return html_sco_header.sco_header() + txth + html_sco_header.sco_footer()
+    return render_template("sco_page.j2", content=txth)
 
 
 @bp.route("/check_formsemestre_integrity")
@@ -2681,6 +2684,4 @@ def check_formsemestre_integrity(formsemestre_id):
     else:
         diag = ["OK"]
         log("ok")
-    return (
-        html_sco_header.sco_header() + "<br>".join(diag) + html_sco_header.sco_footer()
-    )
+    return render_template("sco_page.j2", content="<br>".join(diag))
diff --git a/app/views/users.py b/app/views/users.py
index 0ed67057f..a28252656 100644
--- a/app/views/users.py
+++ b/app/views/users.py
@@ -64,7 +64,7 @@ from app.decorators import (
     permission_required,
 )
 
-from app.scodoc import html_sco_header, sco_import_users, sco_roles_default
+from app.scodoc import sco_import_users, sco_roles_default
 from app.scodoc import sco_users
 from app.scodoc import sco_utils as scu
 from app.scodoc import sco_xml
@@ -74,7 +74,6 @@ from app.scodoc.sco_import_users import generate_password
 from app.scodoc.sco_permissions_check import can_handle_passwd
 from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message
 from app.views import users_bp as bp
-from app.views import scodoc_bp
 
 
 _ = lambda x: x  # sans babel
@@ -92,7 +91,7 @@ class ChangePasswordForm(FlaskForm):
         validators=[
             EqualTo(
                 "new_password",
-                message="Les deux saisies sont " "différentes, recommencez",
+                message="Les deux saisies sont différentes, recommencez",
             ),
         ],
     )
@@ -106,9 +105,9 @@ class ChangePasswordForm(FlaskForm):
     submit = SubmitField()
     cancel = SubmitField("Annuler")
 
-    def validate_email(self, email):
-        "vérifie que le mail est unique"
-        user = User.query.filter_by(email=email.data.strip()).first()
+    def validate_email(self, e_mail):
+        "vérifie que le mail est unique (inline wtf validator)"
+        user = User.query.filter_by(email=e_mail.data.strip()).first()
         if user is not None and self.user_name.data != user.user_name:
             raise ValidationError(
                 _("Cette adresse e-mail est déjà attribuée à un autre compte")
@@ -120,6 +119,7 @@ class ChangePasswordForm(FlaskForm):
             raise ValidationError("Mot de passe trop simple, recommencez")
 
     def validate_old_password(self, old_password):
+        "vérifie password actuel"
         if not current_user.check_password(old_password.data):
             raise ValidationError("Mot de passe actuel incorrect, ré-essayez")
 
@@ -855,9 +855,7 @@ def import_users_generate_excel_sample():
 @scodoc7func
 def import_users_form():
     """Import utilisateurs depuis feuille Excel"""
-    head = html_sco_header.sco_header(page_title="Import utilisateurs")
     H = [
-        head,
         """<h2>Téléchargement d'une liste d'utilisateurs</h2>
          <p style="color: red">A utiliser pour importer de <b>nouveaux</b>
          utilisateurs (enseignants ou secrétaires)
@@ -886,12 +884,10 @@ def import_users_form():
         <li><b>Étape 1: </b><a class="stdlink" href="{
             url_for("users.import_users_generate_excel_sample", scodoc_dept=g.scodoc_dept)
         }">Obtenir la feuille excel vide à remplir</a>
-        ou bien la liste complète des utilisateurs.
         </li>
         <li><b> Étape 2:</b>
         """
     )
-    F = html_sco_header.sco_footer()
     tf = TrivialFormulator(
         request.base_url,
         scu.get_request_args(),
@@ -915,7 +911,11 @@ def import_users_form():
         submitlabel="Télécharger",
     )
     if tf[0] == 0:
-        return "\n".join(H) + tf[1] + "</li></ul>" + help_msg + F
+        return render_template(
+            "sco_page_dept.j2",
+            title="Import utilisateurs",
+            content="\n".join(H) + tf[1] + "</li></ul>" + help_msg,
+        )
     elif tf[0] == -1:
         return flask.redirect(url_for("scolar.index_html", docodc_dept=g.scodoc_dept))
 
@@ -923,8 +923,10 @@ def import_users_form():
     ok, diags, nb_created = sco_import_users.import_excel_file(
         tf[2]["xlsfile"], tf[2]["force"]
     )
-    H = [html_sco_header.sco_header(page_title="Import utilisateurs")]
-    H.append("<ul>")
+    H = [
+        """<h2>Téléchargement d'une liste d'utilisateurs</h2>
+         <ul>"""
+    ]
     for diag in diags:
         H.append(f"<li>{diag}</li>")
     H.append("</ul>")
@@ -942,7 +944,9 @@ def import_users_form():
                 <p><a class="stdlink" href="{dest_url}">Continuer</a></p>
         """
         )
-    return "\n".join(H) + html_sco_header.sco_footer()
+    return render_template(
+        "sco_page_dept.j2", title="Import utilisateurs", content="\n".join(H)
+    )
 
 
 @bp.route("/user_info_page")
@@ -1061,76 +1065,6 @@ def form_change_password(user_name=None):
     )
 
 
-@bp.route("/change_password", methods=["POST"])
-@scodoc
-@permission_required(Permission.ScoView)
-@scodoc7func
-def change_password(user_name, password, password2):
-    "Change the password for user given by user_name"
-    if user_name is not None:  # scodoc7func converti en int !
-        user_name = str(user_name)
-    u = User.query.filter_by(user_name=user_name).first()
-    # Check access permission
-    if not can_handle_passwd(u):
-        # access denied
-        log(
-            f"change_password: access denied (authuser={current_user}, user_name={user_name})"
-        )
-        raise AccessDenied("vous n'avez pas la permission de changer ce mot de passe")
-    H = []
-    F = html_sco_header.sco_footer()
-    # check password
-    dest_url = url_for(
-        "users.form_change_password", scodoc_dept=g.scodoc_dept, user_name=user_name
-    )
-    if password != password2:
-        H.append(
-            f"""<p>Les deux mots de passes saisis sont différents !</p>
-                <p><a href="{dest_url}" class="stdlink">Recommencer</a></p>
-            """
-        )
-    else:
-        if not is_valid_password(password):
-            H.append(
-                f"""<p><b>ce mot de passe n'est pas assez compliqué !</b>
-                <br>(oui, il faut un mot de passe vraiment compliqué !)
-                </p>
-                <p><a href="{dest_url}" class="stdlink">Recommencer</a></p>
-                """
-            )
-        else:
-            # ok, strong password
-            db.session.add(u)
-            u.set_password(password)
-            db.session.commit()
-            #
-            # ici page simplifiee car on peut ne plus avoir
-            # le droit d'acceder aux feuilles de style
-            return f"""<?xml version="1.0" encoding="{scu.SCO_ENCODING}"?>
-<!DOCTYPE html>
-<html>
-<head>
-<title>Mot de passe changé</title>
-<meta http-equiv="Content-Type" content="text/html; charset={scu.SCO_ENCODING}" />
-<body>
-    <h1>Mot de passe changé !</h1>
-    <h2>Changement effectué</h2>
-        <p>Ne notez pas ce mot de passe, mais mémorisez le !</p>
-        <p>Rappel: il est <b>interdit</b> de communiquer son mot de passe à
-        un tiers, même si c'est un collègue de confiance !</p>
-        <p><b>Si vous n'êtes pas administrateur, le système va vous redemander
-        votre login et nouveau mot de passe au prochain accès.</b>
-        </p>
-    <a href="{
-        url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)
-        }"  class="stdlink">Continuer</a>
-</body>
-</html>
-"""
-
-    return html_sco_header.sco_header() + "\n".join(H) + F
-
-
 @bp.route("/toggle_active_user/<user_name>", methods=["GET", "POST"])
 @scodoc
 @permission_required(Permission.UsersAdmin)
-- 
GitLab