From 1c01d987be1a26154d02dc96291a98a1c5984a89 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Sun, 25 Feb 2024 16:58:59 +0100
Subject: [PATCH] =?UTF-8?q?Evaluations=20bloqu=C3=A9es=20jusqu'=C3=A0=20un?=
 =?UTF-8?q?e=20date.=20Implements=20#858?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/but/bulletin_but.py                       | 22 +++--
 app/comp/moy_mod.py                           | 15 ++--
 app/comp/res_common.py                        |  4 +-
 app/models/evaluations.py                     | 59 +++++++------
 app/models/formsemestre.py                    |  4 +
 app/scodoc/sco_bulletins.py                   |  6 +-
 app/scodoc/sco_bulletins_json.py              |  2 +-
 app/scodoc/sco_evaluation_edit.py             | 54 +++++++++++-
 app/scodoc/sco_evaluations.py                 | 43 +++++-----
 app/scodoc/sco_excel.py                       |  2 +-
 app/scodoc/sco_moduleimpl_status.py           | 38 ++++++---
 app/scodoc/sco_saisie_notes.py                |  2 +-
 app/static/css/scodoc.css                     | 21 +++++
 app/tables/recap.py                           |  2 +-
 .../assiduites/pages/etat_abs_date.j2         |  2 +-
 app/views/notes.py                            |  2 +-
 .../cddabc3f868a_evaluation_bloquee.py        | 83 +++++++++++++++++++
 17 files changed, 272 insertions(+), 89 deletions(-)
 create mode 100644 migrations/versions/cddabc3f868a_evaluation_bloquee.py

diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py
index 87b175468..b73b871a8 100644
--- a/app/but/bulletin_but.py
+++ b/app/but/bulletin_but.py
@@ -291,15 +291,19 @@ class BulletinBUT:
             "date_fin": e.date_fin.isoformat() if e.date_fin else None,
             "description": e.description,
             "evaluation_type": e.evaluation_type,
-            "note": {
-                "value": fmt_note(
-                    eval_notes[etud.id],
-                    note_max=e.note_max,
-                ),
-                "min": fmt_note(notes_ok.min(), note_max=e.note_max),
-                "max": fmt_note(notes_ok.max(), note_max=e.note_max),
-                "moy": fmt_note(notes_ok.mean(), note_max=e.note_max),
-            },
+            "note": (
+                {
+                    "value": fmt_note(
+                        eval_notes[etud.id],
+                        note_max=e.note_max,
+                    ),
+                    "min": fmt_note(notes_ok.min(), note_max=e.note_max),
+                    "max": fmt_note(notes_ok.max(), note_max=e.note_max),
+                    "moy": fmt_note(notes_ok.mean(), note_max=e.note_max),
+                }
+                if not e.is_blocked()
+                else {}
+            ),
             "poids": poids,
             "url": (
                 url_for(
diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py
index a977894d6..b1af5c062 100644
--- a/app/comp/moy_mod.py
+++ b/app/comp/moy_mod.py
@@ -35,7 +35,6 @@ moyenne générale d'une UE.
 """
 import dataclasses
 from dataclasses import dataclass
-
 import numpy as np
 import pandas as pd
 import sqlalchemy as sa
@@ -151,16 +150,18 @@ class ModuleImplResults:
         self.evaluations_completes_dict = {}
         for evaluation in moduleimpl.evaluations:
             eval_df = self._load_evaluation_notes(evaluation)
-            # is_complete ssi tous les inscrits (non dem) au semestre ont une note
-            # ou évaluation déclarée "à prise en compte immédiate"
-            # Les évaluations de rattrapage et 2eme session sont toujours complètes
+            # is_complete ssi
+            #  tous les inscrits (non dem) au module ont une note
+            #  ou évaluation déclarée "à prise en compte immédiate"
+            #  ou rattrapage, 2eme session, bonus
+            #  ET pas bloquée par date (is_blocked)
 
             etudids_sans_note = inscrits_module - set(eval_df.index)  # sans les dem.
             is_complete = (
                 (evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE)
                 or (evaluation.publish_incomplete)
                 or (not etudids_sans_note)
-            )
+            ) and not evaluation.is_blocked()
             self.evaluations_completes.append(is_complete)
             self.evaluations_completes_dict[evaluation.id] = is_complete
             self.evals_etudids_sans_note[evaluation.id] = etudids_sans_note
@@ -185,7 +186,7 @@ class ModuleImplResults:
                 ].index
             )
             if evaluation.publish_incomplete:
-                # et en "imédiat", tous ceux sans note
+                # et en "immédiat", tous ceux sans note
                 eval_etudids_attente |= etudids_sans_note
             # Synthèse pour état du module:
             self.etudids_attente |= eval_etudids_attente
@@ -276,7 +277,7 @@ class ModuleImplResults:
         ) / [e.note_max / 20.0 for e in moduleimpl.evaluations]
 
     def get_eval_notes_dict(self, evaluation_id: int) -> dict:
-        """Notes d'une évaulation, brutes, sous forme d'un dict
+        """Notes d'une évaluation, brutes, sous forme d'un dict
         { etudid : valeur }
         avec les valeurs float, ou "ABS" ou EXC
         """
diff --git a/app/comp/res_common.py b/app/comp/res_common.py
index 954f35235..9ba073a4f 100644
--- a/app/comp/res_common.py
+++ b/app/comp/res_common.py
@@ -230,8 +230,8 @@ class ResultatsSemestre(ResultatsCache):
         date_modif = cursor.one_or_none()
         last_modif = date_modif[0] if date_modif else None
         return {
-            "coefficient": evaluation.coefficient or 0.0,
-            "description": evaluation.description or "",
+            "coefficient": evaluation.coefficient,
+            "description": evaluation.description,
             "evaluation_id": evaluation.id,
             "jour": evaluation.date_debut or datetime.datetime(1900, 1, 1),
             "etat": {
diff --git a/app/models/evaluations.py b/app/models/evaluations.py
index 37e3ac794..cea6c62e0 100644
--- a/app/models/evaluations.py
+++ b/app/models/evaluations.py
@@ -10,6 +10,7 @@ from flask_login import current_user
 import sqlalchemy as sa
 
 from app import db, log
+from app import models
 from app.models.etudiants import Identite
 from app.models.events import ScolarNews
 from app.models.notes import NotesNotes
@@ -24,7 +25,7 @@ NOON = datetime.time(12, 00)
 DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
 
 
-class Evaluation(db.Model):
+class Evaluation(models.ScoDocModel):
     """Evaluation (contrôle, examen, ...)"""
 
     __tablename__ = "notes_evaluation"
@@ -36,9 +37,9 @@ class Evaluation(db.Model):
     )
     date_debut = db.Column(db.DateTime(timezone=True), nullable=True)
     date_fin = db.Column(db.DateTime(timezone=True), nullable=True)
-    description = db.Column(db.Text)
-    note_max = db.Column(db.Float)
-    coefficient = db.Column(db.Float)
+    description = db.Column(db.Text, nullable=False)
+    note_max = db.Column(db.Float, nullable=False)
+    coefficient = db.Column(db.Float, nullable=False)
     visibulletin = db.Column(
         db.Boolean, nullable=False, default=True, server_default="true"
     )
@@ -46,10 +47,14 @@ class Evaluation(db.Model):
     publish_incomplete = db.Column(
         db.Boolean, nullable=False, default=False, server_default="false"
     )
-    # type d'evaluation: 0 normale, 1 rattrapage, 2 "2eme session"
+    "prise en compte immédiate"
     evaluation_type = db.Column(
         db.Integer, nullable=False, default=0, server_default="0"
     )
+    "type d'evaluation: 0 normale, 1 rattrapage, 2 2eme session, 3 bonus"
+    blocked_until = db.Column(db.DateTime(timezone=True), nullable=True)
+    "date de prise en compte"
+    BLOCKED_FOREVER = datetime.datetime(2666, 12, 31, tzinfo=scu.TIME_ZONE)
     # ordre de presentation (par défaut, le plus petit numero
     # est la plus ancienne eval):
     numero = db.Column(db.Integer, nullable=False, default=0)
@@ -79,6 +84,7 @@ class Evaluation(db.Model):
         date_fin: datetime.datetime = None,
         description=None,
         note_max=None,
+        blocked_until=None,
         coefficient=None,
         visibulletin=None,
         publish_incomplete=None,
@@ -208,6 +214,10 @@ class Evaluation(db.Model):
     def to_dict_api(self) -> dict:
         "Représentation dict pour API JSON"
         return {
+            "blocked": self.is_blocked(),
+            "blocked_until": (
+                self.blocked_until.isoformat() if self.blocked_until else ""
+            ),
             "coefficient": self.coefficient,
             "date_debut": self.date_debut.isoformat() if self.date_debut else "",
             "date_fin": self.date_fin.isoformat() if self.date_fin else "",
@@ -244,14 +254,14 @@ class Evaluation(db.Model):
 
         return e_dict
 
-    def from_dict(self, data):
-        """Set evaluation attributes from given dict values."""
-        check_convert_evaluation_args(self.moduleimpl, data)
-        if data.get("numero") is None:
-            data["numero"] = Evaluation.get_max_numero(self.moduleimpl.id) + 1
-        for k in self.__dict__:
-            if k != "_sa_instance_state" and k != "id" and k in data:
-                setattr(self, k, data[k])
+    def convert_dict_fields(self, args: dict) -> dict:
+        """Convert fields in the given dict. No other side effect.
+        returns: dict to store in model's db.
+        """
+        check_convert_evaluation_args(self.moduleimpl, args)
+        if args.get("numero") is None:
+            args["numero"] = Evaluation.get_max_numero(self.moduleimpl.id) + 1
+        return args
 
     @classmethod
     def get_evaluation(
@@ -370,19 +380,6 @@ class Evaluation(db.Model):
         Chaine vide si non renseignée."""
         return self.date_fin.time().isoformat("minutes") if self.date_fin else ""
 
-    def clone(self, not_copying=()):
-        """Clone, not copying the given attrs
-        Attention: la copie n'a pas d'id avant le prochain commit
-        """
-        d = dict(self.__dict__)
-        d.pop("id")  # get rid of id
-        d.pop("_sa_instance_state")  # get rid of SQLAlchemy special attr
-        for k in not_copying:
-            d.pop(k)
-        copy = self.__class__(**d)
-        db.session.add(copy)
-        return copy
-
     def is_matin(self) -> bool:
         "Evaluation commençant le matin (faux si pas de date)"
         if not self.date_debut:
@@ -395,6 +392,14 @@ class Evaluation(db.Model):
             return False
         return self.date_debut.time() >= NOON
 
+    def is_blocked(self, now=None) -> bool:
+        "True si prise en compte bloquée"
+        if self.blocked_until is None:
+            return False
+        if now is None:
+            now = datetime.datetime.now(scu.TIME_ZONE)
+        return self.blocked_until > now
+
     def set_default_poids(self) -> bool:
         """Initialize les poids vers les UE à leurs valeurs par défaut
         C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon.
@@ -621,6 +626,8 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
                 "Heures de l'évaluation incohérentes !",
                 dest_url="javascript:history.back();",
             )
+    if "blocked_until" in data:
+        data["blocked_until"] = data["blocked_until"] or None
 
 
 def heure_to_time(heure: str) -> datetime.time:
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index 9d2f5b134..09c1d3056 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -93,6 +93,10 @@ class FormSemestre(db.Model):
         db.Boolean(), nullable=False, default=False, server_default="false"
     )
     "Si vrai, la moyenne générale indicative BUT n'est pas calculée"
+    mode_calcul_moyennes = db.Column(
+        db.Integer, nullable=False, default=0, server_default="0"
+    )
+    "pour usage futur"
     gestion_semestrielle = db.Column(
         db.Boolean(), nullable=False, default=False, server_default="false"
     )
diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py
index 69c13df36..1b70d3858 100644
--- a/app/scodoc/sco_bulletins.py
+++ b/app/scodoc/sco_bulletins.py
@@ -318,7 +318,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
             if nt.bonus_ues is not None:
                 u["cur_moy_ue_txt"] += " (+ues)"
         u["moy_ue_txt"] = scu.fmt_note(ue_status["moy"])
-        if ue_status["coef_ue"] != None:
+        if ue_status["coef_ue"] is not None:
             u["coef_ue_txt"] = scu.fmt_coef(ue_status["coef_ue"])
         else:
             u["coef_ue_txt"] = "-"
@@ -558,6 +558,8 @@ def _ue_mod_bulletin(
             ).order_by(Evaluation.numero, Evaluation.date_debut)
             # (plus ancienne d'abord)
             for e in all_evals:
+                if e.is_blocked():
+                    continue  # ignore évaluations bloquées
                 if not e.visibulletin and version != "long":
                     continue
                 is_complete = e.id in complete_eval_ids
@@ -625,7 +627,7 @@ def _ue_mod_bulletin(
                     )
                 ):
                     # ne liste pas les eval malus sans notes
-                    # ni les rattrapages et sessions 2 si pas de note
+                    # ni les rattrapages, sessions 2 et bonus si pas de note
                     if e.id in complete_eval_ids:
                         mod["evaluations"].append(e_dict)
                     else:
diff --git a/app/scodoc/sco_bulletins_json.py b/app/scodoc/sco_bulletins_json.py
index 3bce083a9..0481e6f9c 100644
--- a/app/scodoc/sco_bulletins_json.py
+++ b/app/scodoc/sco_bulletins_json.py
@@ -25,7 +25,7 @@
 #
 ##############################################################################
 
-"""Génération du bulletin en format JSON
+"""Génération du bulletin en format JSON (formations classiques)
 
 """
 import datetime
diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py
index c7b0dd676..11418a99a 100644
--- a/app/scodoc/sco_evaluation_edit.py
+++ b/app/scodoc/sco_evaluation_edit.py
@@ -108,7 +108,7 @@ def evaluation_create_form(
             raise ValueError("missing evaluation_id parameter")
         initvalues = evaluation.to_dict()
         moduleimpl_id = initvalues["moduleimpl_id"]
-        submitlabel = "Modifier les données"
+        submitlabel = "Modifier l'évaluation"
         action = "Modification d'une évaluation"
         link = ""
         # Note maximale actuelle dans cette éval ?
@@ -142,6 +142,15 @@ def evaluation_create_form(
                 else:
                     poids = 0.0
                 initvalues[f"poids_{ue.id}"] = poids
+    # Blocage
+    if edit:
+        initvalues["blocked"] = evaluation.is_blocked()
+        initvalues["blocked_until"] = (
+            evaluation.blocked_until.strftime("%d/%m/%Y")
+            if evaluation.blocked_until
+            and evaluation.blocked_until < Evaluation.BLOCKED_FOREVER
+            else ""
+        )
     #
     form = [
         ("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}),
@@ -260,6 +269,7 @@ def evaluation_create_form(
                     "explanation": """importance de l'évaluation (multiplie les poids ci-dessous).
                         Non utilisé pour les bonus.""",
                     "allow_null": False,
+                    "dom_id": "evaluation-edit-coef",
                 },
             ),
         ]
@@ -301,6 +311,28 @@ def evaluation_create_form(
                     },
                 ),
             )
+    # Bloquage / date prise en compte
+    form += [
+        (
+            "blocked",
+            {
+                "input_type": "boolcheckbox",
+                "title": "Bloquer la prise en compte",
+                "explanation": """empêche la prise en compte
+                    (ne sera pas visible sur les bulletins ni dans les tableaux)""",
+                "dom_id": "evaluation-edit-blocked",
+            },
+        ),
+        (
+            "blocked_until",
+            {
+                "input_type": "datedmy",
+                "title": "Date déblocage",
+                "size": 12,
+                "explanation": "sera débloquée à partir de cette date",
+            },
+        ),
+    ]
     tf = TrivialFormulator(
         request.base_url,
         vals,
@@ -331,7 +363,9 @@ def evaluation_create_form(
             + "\n".join(H)
             + "\n"
             + tf[1]
-            + render_template("scodoc/help/evaluations.j2", is_apc=is_apc)
+            + render_template(
+                "scodoc/help/evaluations.j2", is_apc=is_apc, modimpl=modimpl
+            )
             + render_template("sco_timepicker.j2")
             + html_sco_header.sco_footer()
         )
@@ -357,7 +391,8 @@ def evaluation_create_form(
                 raise ScoValueError("Heure début invalide") from exc
             args["date_debut"] = datetime.datetime.combine(date_debut, heure_debut)
         args.pop("heure_debut", None)
-        # note: ce formulaire ne permet de créer que des évaluation avec debut et fin sur le même jour.
+        # note: ce formulaire ne permet de créer que des évaluations
+        # avec debut et fin sur le même jour.
         if date_debut and args.get("heure_fin"):
             try:
                 heure_fin = heure_to_time(args["heure_fin"])
@@ -365,6 +400,19 @@ def evaluation_create_form(
                 raise ScoValueError("Heure fin invalide") from exc
             args["date_fin"] = datetime.datetime.combine(date_debut, heure_fin)
         args.pop("heure_fin", None)
+        # Blocage:
+        if args.get("blocked"):
+            if args.get("blocked_until"):
+                try:
+                    args["blocked_until"] = datetime.datetime.strptime(
+                        args["blocked_until"], "%d/%m/%Y"
+                    )
+                except ValueError as exc:
+                    raise ScoValueError("Date déblocage (j/m/a) invalide") from exc
+            else:  # bloquage coché sans date
+                args["blocked_until"] = Evaluation.BLOCKED_FOREVER
+        else:  # si pas coché, efface date déblocage
+            args["blocked_until"] = None
         #
         if edit:
             evaluation.from_dict(args)
diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py
index d157e0a30..8931faf2c 100644
--- a/app/scodoc/sco_evaluations.py
+++ b/app/scodoc/sco_evaluations.py
@@ -40,7 +40,7 @@ from app import db
 from app.auth.models import User
 from app.comp import res_sem
 from app.comp.res_compat import NotesTableCompat
-from app.models import Evaluation, FormSemestre, ModuleImpl
+from app.models import Evaluation, FormSemestre, ModuleImpl, Module
 
 import app.scodoc.sco_utils as scu
 from app.scodoc.sco_utils import ModuleType
@@ -48,7 +48,6 @@ from app.scodoc.gen_tables import GenTable
 from app.scodoc import html_sco_header
 from app.scodoc import sco_cal
 from app.scodoc import sco_evaluation_db
-from app.scodoc import sco_edit_module
 from app.scodoc import sco_formsemestre_inscriptions
 from app.scodoc import sco_groups
 from app.scodoc import sco_moduleimpl
@@ -113,6 +112,7 @@ def do_evaluation_etat(
         nb_neutre,
         nb_att,
         moy, median, mini, maxi : # notes, en chaine, sur 20
+        maxi_num : note max, numérique
         last_modif: datetime, *
         gr_complets, gr_incomplets,
         evalcomplete *
@@ -129,11 +129,12 @@ def do_evaluation_etat(
     )  # { etudid : note }
 
     # ---- Liste des groupes complets et incomplets
-    E = sco_evaluation_db.get_evaluations_dict(args={"evaluation_id": evaluation_id})[0]
-    M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
-    Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
-    is_malus = Mod["module_type"] == ModuleType.MALUS  # True si module de malus
-    formsemestre_id = M["formsemestre_id"]
+    evaluation = Evaluation.get_evaluation(evaluation_id)
+    modimpl: ModuleImpl = evaluation.moduleimpl
+    module: Module = modimpl.module
+
+    is_malus = module.module_type == ModuleType.MALUS  # True si module de malus
+    formsemestre_id = modimpl.formsemestre_id
     # Si partition_id is None, prend 'all' ou bien la premiere:
     if partition_id is None:
         if select_first_partition:
@@ -149,9 +150,7 @@ def do_evaluation_etat(
     insem = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits(
         formsemestre_id
     )
-    insmod = sco_moduleimpl.do_moduleimpl_inscription_list(
-        moduleimpl_id=E["moduleimpl_id"]
-    )
+    insmod = sco_moduleimpl.do_moduleimpl_inscription_list(moduleimpl_id=modimpl.id)
     insmodset = {x["etudid"] for x in insmod}
     # retire de insem ceux qui ne sont pas inscrits au module
     ins = [i for i in insem if i["etudid"] in insmodset]
@@ -174,9 +173,9 @@ def do_evaluation_etat(
         maxi_num = None
     else:
         median = scu.fmt_note(median_num)
-        moy = scu.fmt_note(moy_num, E["note_max"])
-        mini = scu.fmt_note(mini_num, E["note_max"])
-        maxi = scu.fmt_note(maxi_num, E["note_max"])
+        moy = scu.fmt_note(moy_num, evaluation.note_max)
+        mini = scu.fmt_note(mini_num, evaluation.note_max)
+        maxi = scu.fmt_note(maxi_num, evaluation.note_max)
     # cherche date derniere modif note
     if len(etuds_notes_dict):
         t = [x["date"] for x in etuds_notes_dict.values()]
@@ -218,14 +217,16 @@ def do_evaluation_etat(
     gr_incomplets = list(group_nb_missing.keys())
     gr_incomplets.sort()
 
-    complete = (total_nb_missing == 0) or (
-        E["evaluation_type"] != Evaluation.EVALUATION_NORMALE
+    complete = (
+        (total_nb_missing == 0)
+        or (evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE)
+        and not evaluation.is_blocked()
     )
     evalattente = (total_nb_missing > 0) and (
-        (total_nb_missing == total_nb_att) or E["publish_incomplete"]
+        (total_nb_missing == total_nb_att) or evaluation.publish_incomplete
     )
     # mais ne met pas en attente les evals immediates sans aucune notes:
-    if E["publish_incomplete"] and nb_notes == 0:
+    if evaluation.publish_incomplete and nb_notes == 0:
         evalattente = False
 
     # Calcul moyenne dans chaque groupe de TD
@@ -236,10 +237,10 @@ def do_evaluation_etat(
             {
                 "group_id": group_id,
                 "group_name": group_by_id[group_id]["group_name"],
-                "gr_moy": scu.fmt_note(gr_moy, E["note_max"]),
-                "gr_median": scu.fmt_note(gr_median, E["note_max"]),
-                "gr_mini": scu.fmt_note(gr_mini, E["note_max"]),
-                "gr_maxi": scu.fmt_note(gr_maxi, E["note_max"]),
+                "gr_moy": scu.fmt_note(gr_moy, evaluation.note_max),
+                "gr_median": scu.fmt_note(gr_median, evaluation.note_max),
+                "gr_mini": scu.fmt_note(gr_mini, evaluation.note_max),
+                "gr_maxi": scu.fmt_note(gr_maxi, evaluation.note_max),
                 "gr_nb_notes": len(notes),
                 "gr_nb_att": len([x for x in notes if x == scu.NOTES_ATTENTE]),
             }
diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py
index 0d4095b6f..ea72d2b58 100644
--- a/app/scodoc/sco_excel.py
+++ b/app/scodoc/sco_excel.py
@@ -534,7 +534,7 @@ def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, line
     # description evaluation
     ws.append_single_cell_row(scu.unescape_html(description), style_titres)
     ws.append_single_cell_row(
-        f"Evaluation {evaluation.descr_date()} (coef. {(evaluation.coefficient or 0.0):g})",
+        f"Evaluation {evaluation.descr_date()} (coef. {(evaluation.coefficient):g})",
         style,
     )
     # ligne blanche
diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py
index 28021e13f..336d49ff2 100644
--- a/app/scodoc/sco_moduleimpl_status.py
+++ b/app/scodoc/sco_moduleimpl_status.py
@@ -531,6 +531,10 @@ def _ligne_evaluation(
     if not evaluation.visibulletin:
         tr_class += " non_visible_inter"
     tr_class_1 = "mievr"
+    if evaluation.is_blocked():
+        tr_class += " evaluation_blocked"
+        tr_class_1 += " evaluation_blocked"
+
     if not first_eval:
         H.append("""<tr><td colspan="8">&nbsp;</td></tr>""")
         tr_class_1 += " mievr_spaced"
@@ -564,7 +568,7 @@ def _ligne_evaluation(
                     scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
                 }" class="mievr_evalnodate">Évaluation sans date</a>"""
         )
-    H.append(f"&nbsp;&nbsp;&nbsp; <em>{evaluation.description or ''}</em>")
+    H.append(f"&nbsp;&nbsp;&nbsp; <em>{evaluation.description}</em>")
     if evaluation.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE:
         H.append(
             """<span class="mievr_rattr" title="remplace si meilleure note">rattrapage</span>"""
@@ -611,8 +615,15 @@ def _ligne_evaluation(
     else:
         H.append(arrow_none)
 
-    if etat["evalcomplete"]:
-        etat_txt = f"""(prise en compte{
+    if evaluation.is_blocked():
+        etat_txt = f"""évaluation bloquée {
+            "jusqu'au " + evaluation.blocked_until.strftime("%d/%m/%Y")
+            if evaluation.blocked_until < Evaluation.BLOCKED_FOREVER
+            else "" }
+        """
+        etat_descr = """prise en compte bloquée"""
+    elif etat["evalcomplete"]:
+        etat_txt = f"""Moyenne (prise en compte{
             ""
             if evaluation.visibulletin
             else ", cachée en intermédiaire"})
@@ -621,7 +632,7 @@ def _ligne_evaluation(
             ", évaluation cachée sur les bulletins en version intermédiaire et sur la passerelle"
             }"""
     elif etat["evalattente"] and not evaluation.publish_incomplete:
-        etat_txt = "(prise en compte, mais <b>notes en attente</b>)"
+        etat_txt = "Moyenne (prise en compte, mais <b>notes en attente</b>)"
         etat_descr = "il y a des notes en attente"
     elif evaluation.publish_incomplete:
         etat_txt = """(prise en compte <b>immédiate</b>)"""
@@ -629,28 +640,29 @@ def _ligne_evaluation(
             "il manque des notes, mais la prise en compte immédiate a été demandée"
         )
     elif etat["nb_notes"] != 0:
-        etat_txt = "(<b>non</b> prise en compte)"
+        etat_txt = "Moyenne (<b>non</b> prise en compte)"
         etat_descr = "il manque des notes"
     else:
         etat_txt = ""
-    if can_edit_evals and etat_txt:
-        etat_txt = f"""<a href="{ url_for("notes.evaluation_edit",
-            scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
-            }" title="{etat_descr}">{etat_txt}</a>"""
+    if etat_txt:
+        if can_edit_evals:
+            etat_txt = f"""<a href="{ url_for("notes.evaluation_edit",
+                scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
+                }" title="{etat_descr}">{etat_txt}</a>"""
 
     H.append(
         f"""</span></span></td>
         </tr>
-        <tr class="{tr_class}">
+        <tr class="{tr_class} mievr_in">
             <th class="moduleimpl_evaluations" colspan="2">&nbsp;</th>
             <th class="moduleimpl_evaluations">Durée</th>
             <th class="moduleimpl_evaluations">Coef.</th>
             <th class="moduleimpl_evaluations">Notes</th>
             <th class="moduleimpl_evaluations">Abs</th>
             <th class="moduleimpl_evaluations">N</th>
-            <th class="moduleimpl_evaluations" colspan="2">Moyenne {etat_txt}</th>
+            <th class="moduleimpl_evaluations moduleimpl_evaluation_moy" colspan="2"><span>{etat_txt}</span></th>
         </tr>
-        <tr class="{tr_class}">
+        <tr class="{tr_class} mievr_in">
             <td class="mievr">"""
     )
     if can_edit_evals:
@@ -832,7 +844,7 @@ def _evaluation_poids_html(evaluation: Evaluation, max_poids: float = 0.0) -> st
         + "\n".join(
             [
                 f"""<div title="poids vers {ue.acronyme}: {poids:g}">
-                    <div style="--size:{math.sqrt(poids*(evaluation.coefficient or 0.)/max_poids*144)}px;
+                    <div style="--size:{math.sqrt(poids*(evaluation.coefficient)/max_poids*144)}px;
                         {'background-color: ' + ue.color + ';' if ue.color else ''}
                     "></div>
                 </div>"""
diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py
index a9195d29b..7110d3d22 100644
--- a/app/scodoc/sco_saisie_notes.py
+++ b/app/scodoc/sco_saisie_notes.py
@@ -884,7 +884,7 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]):
     if evaluation.date_debut:
         indication_date = evaluation.date_debut.date().isoformat()
     else:
-        indication_date = scu.sanitize_filename(evaluation.description or "")[:12]
+        indication_date = scu.sanitize_filename(evaluation.description)[:12]
     eval_name = f"{evaluation.moduleimpl.module.code}-{indication_date}"
 
     date_str = (
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index a5e8d173d..23e0b42e7 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -1469,6 +1469,9 @@ span.eval_title {
   font-size: 14pt;
 }
 
+#evaluation-edit-blocked td, #evaluation-edit-coef td {
+  padding-top: 24px;
+}
 /* #saisie_notes span.eval_title {
     border-bottom: 1px solid rgb(100,100,100);
 }
@@ -2099,6 +2102,14 @@ th.moduleimpl_evaluations a:hover {
   text-decoration: underline;
 }
 
+tr.mievr_in.evaluation_blocked th.moduleimpl_evaluation_moy span, tr.evaluation_blocked th.moduleimpl_evaluation_moy a {
+  font-weight: bold;
+  color: red;
+  background-color: yellow;
+  padding: 2px;
+  border-radius: 2px;
+}
+
 tr.mievr {
   background-color: #eeeeee;
 }
@@ -2153,6 +2164,15 @@ tr.mievr.non_visible_inter th {
   );
 }
 
+tr.mievr_tit.evaluation_blocked td,tr.mievr_tit.evaluation_blocked th {
+  background-image: radial-gradient(#bd7777 1px, transparent 1px);
+  background-size: 10px 10px;
+}
+tr.mievr_in.evaluation_blocked td, tr.mievr_in.evaluation_blocked th {
+  background-color: rgb(195, 235, 255);
+}
+
+
 tr.mievr th {
   background-color: white;
 }
@@ -2163,6 +2183,7 @@ tr.mievr td.mievr {
 
 tr.mievr td.mievr_menu {
   width: 110px;
+  padding-bottom: 4px;
 }
 
 tr.mievr td.mievr_dur {
diff --git a/app/tables/recap.py b/app/tables/recap.py
index 0e2872037..01947f631 100644
--- a/app/tables/recap.py
+++ b/app/tables/recap.py
@@ -457,7 +457,7 @@ class TableRecap(tb.Table):
                 row_descr_eval.add_cell(
                     col_id,
                     None,
-                    e.description or "",
+                    e.description,
                     target=url_for(
                         "notes.evaluation_listenotes",
                         scodoc_dept=g.scodoc_dept,
diff --git a/app/templates/assiduites/pages/etat_abs_date.j2 b/app/templates/assiduites/pages/etat_abs_date.j2
index d40b61e8c..22ecb8e56 100644
--- a/app/templates/assiduites/pages/etat_abs_date.j2
+++ b/app/templates/assiduites/pages/etat_abs_date.j2
@@ -20,7 +20,7 @@ Assiduité lors de l'évaluation
     <a class="stdlink" href="{{
         url_for('notes.evaluation_listenotes',
             scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
-        }}"><em>{{evaluation.description or ''}}</em></a>
+        }}"><em>{{evaluation.description}}</em></a>
 {% endif %}
 <a style="margin-left:32px;" href="{{request.url}}&fmt=xlsx">{{scu.ICON_XLS|safe}}</a>
 </div>
diff --git a/app/views/notes.py b/app/views/notes.py
index 251702888..1dfec6745 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -1642,7 +1642,7 @@ def evaluation_delete(evaluation_id):
         .first_or_404()
     )
 
-    tit = f"""Suppression de l'évaluation {evaluation.description or ""} ({evaluation.descr_date()})"""
+    tit = f"""Suppression de l'évaluation {evaluation.description} ({evaluation.descr_date()})"""
     etat = sco_evaluations.do_evaluation_etat(evaluation.id)
     H = [
         f"""
diff --git a/migrations/versions/cddabc3f868a_evaluation_bloquee.py b/migrations/versions/cddabc3f868a_evaluation_bloquee.py
new file mode 100644
index 000000000..567fd960e
--- /dev/null
+++ b/migrations/versions/cddabc3f868a_evaluation_bloquee.py
@@ -0,0 +1,83 @@
+"""evaluation bloquee
+
+Revision ID: cddabc3f868a
+Revises: 2e4875004e12
+Create Date: 2024-02-25 16:39:45.947342
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.orm import sessionmaker  # added by ev
+
+
+# revision identifiers, used by Alembic.
+revision = "cddabc3f868a"
+down_revision = "2e4875004e12"
+branch_labels = None
+depends_on = None
+
+Session = sessionmaker()
+
+
+def upgrade():
+    # ces champs étaient nullables
+    # Added by ev: remove duplicates
+    bind = op.get_bind()
+    session = Session(bind=bind)
+    session.execute(
+        sa.text(
+            """UPDATE notes_evaluation SET description='' WHERE description IS NULL;"""
+        )
+    )
+    session.execute(
+        sa.text("""UPDATE notes_evaluation SET note_max=20. WHERE note_max IS NULL;""")
+    )
+    session.execute(
+        sa.text(
+            """UPDATE notes_evaluation SET coefficient=0. WHERE coefficient IS NULL;"""
+        )
+    )
+    #
+    with op.batch_alter_table("notes_evaluation", schema=None) as batch_op:
+        batch_op.add_column(
+            sa.Column("blocked_until", sa.DateTime(timezone=True), nullable=True)
+        )
+        batch_op.alter_column("description", existing_type=sa.TEXT(), nullable=False)
+        batch_op.alter_column(
+            "note_max", existing_type=sa.DOUBLE_PRECISION(precision=53), nullable=False
+        )
+        batch_op.alter_column(
+            "coefficient",
+            existing_type=sa.DOUBLE_PRECISION(precision=53),
+            nullable=False,
+        )
+
+    with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op:
+        batch_op.add_column(
+            sa.Column(
+                "mode_calcul_moyennes", sa.Integer(), server_default="0", nullable=False
+            )
+        )
+
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op:
+        batch_op.drop_column("mode_calcul_moyennes")
+
+    with op.batch_alter_table("notes_evaluation", schema=None) as batch_op:
+        batch_op.alter_column(
+            "coefficient",
+            existing_type=sa.DOUBLE_PRECISION(precision=53),
+            nullable=True,
+        )
+        batch_op.alter_column(
+            "note_max", existing_type=sa.DOUBLE_PRECISION(precision=53), nullable=True
+        )
+        batch_op.alter_column("description", existing_type=sa.TEXT(), nullable=True)
+        batch_op.drop_column("blocked_until")
+
+    # ### end Alembic commands ###
-- 
GitLab