From ca04f3d5cb321ef567573dc97c1415fcee3bd2be Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Tue, 22 Aug 2023 17:02:00 +0200
Subject: [PATCH] WIP: modernisation evaluations

---
 app/api/evaluations.py                        |  69 +++-
 app/but/bulletin_but.py                       |  11 +-
 app/but/bulletin_but_xml_compat.py            |  19 +-
 app/models/evaluations.py                     | 324 ++++++++++++++----
 app/models/moduleimpls.py                     |  17 +
 app/scodoc/notesdb.py                         |  23 +-
 app/scodoc/sco_bulletins_json.py              |  29 +-
 app/scodoc/sco_bulletins_xml.py               |  13 +-
 app/scodoc/sco_evaluation_db.py               | 180 +---------
 app/scodoc/sco_evaluation_edit.py             |   9 +-
 app/scodoc/sco_moduleimpl_status.py           |   3 +-
 app/scodoc/sco_permissions_check.py           |  28 --
 app/scodoc/sco_ue_external.py                 |  17 +-
 app/scodoc/sco_utils.py                       |   3 +
 app/views/notes.py                            |  42 ++-
 .../versions/5c44d0d215ca_evaluation_date.py  |  58 ++++
 scodoc.py                                     |   3 +-
 tests/api/exemple-api-basic.py                |   9 +-
 tests/api/exemple-api-scodoc7.py              |  34 --
 tests/api/test_api_evaluations.py             |  76 ++--
 tests/unit/sco_fake_gen.py                    |  22 +-
 .../fakedatabase/create_test_api_database.py  |  15 +-
 tools/test_api.sh                             |   9 +-
 23 files changed, 604 insertions(+), 409 deletions(-)
 create mode 100644 migrations/versions/5c44d0d215ca_evaluation_date.py

diff --git a/app/api/evaluations.py b/app/api/evaluations.py
index af1c40af3..7fd84c539 100644
--- a/app/api/evaluations.py
+++ b/app/api/evaluations.py
@@ -7,17 +7,19 @@
 """
   ScoDoc 9 API : accès aux évaluations
 """
+import datetime
 
 from flask import g, request
 from flask_json import as_json
-from flask_login import login_required
+from flask_login import current_user, login_required
 
 import app
-
+from app import db
 from app.api import api_bp as bp, api_web_bp
 from app.decorators import scodoc, permission_required
 from app.models import Evaluation, ModuleImpl, FormSemestre
-from app.scodoc import sco_evaluation_db, sco_saisie_notes
+from app.scodoc import sco_evaluation_db, sco_permissions_check, sco_saisie_notes
+from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
 from app.scodoc.sco_permissions import Permission
 import app.scodoc.sco_utils as scu
 
@@ -181,3 +183,64 @@ def evaluation_set_notes(evaluation_id: int):
     return sco_saisie_notes.save_notes(
         evaluation, notes, comment=data.get("comment", "")
     )
+
+
+@bp.route("/moduleimpl/<int:moduleimpl_id>/evaluation/create", methods=["POST"])
+@api_web_bp.route("/moduleimpl/<int:moduleimpl_id>/evaluation/create", methods=["POST"])
+@login_required
+@scodoc
+@permission_required(Permission.ScoEnsView)  # permission gérée dans la fonction
+@as_json
+def evaluation_create(moduleimpl_id: int):
+    """Création d'une évaluation.
+    The request content type should be "application/json",
+    and contains:
+    {
+        "description" : str,
+        "evaluation_type" : int,  // {0,1,2} default 0 (normale)
+        "jour" : date_iso, // si non spécifié, vide
+        "date_debut" : date_iso, // optionnel
+        "date_fin" : date_iso, // si non spécifié, 08:00
+        "note_max" : float, // si non spécifié, 20.0
+        "numero" : int,  // ordre de présentation, default tri sur date
+        "visibulletin" : boolean ,  //default true
+        "publish_incomplete" : boolean ,  //default false
+        "coefficient" : float, // si non spécifié, 1.0
+        "poids" : [ {
+                        "ue_id": int,
+                        "poids": float
+                    },
+                    ...
+                ]  // si non spécifié, tous les poids à 1.0
+        }
+    }
+    Result: l'évaluation créée.
+    """
+    moduleimpl: ModuleImpl = ModuleImpl.query.get_or_404(moduleimpl_id)
+    if not moduleimpl.can_edit_evaluation(current_user):
+        return scu.json_error(403, "opération non autorisée")
+    data = request.get_json(force=True)  # may raise 400 Bad Request
+
+    try:
+        evaluation = Evaluation.create(moduleimpl=moduleimpl, **data)
+    except AccessDenied:
+        return scu.json_error(403, "opération non autorisée (2)")
+    except ValueError:
+        return scu.json_error(400, "paramètre incorrect")
+    except ScoValueError as exc:
+        breakpoint()  # XXX WIP
+        return scu.json_error(400, f"paramètre de type incorrect ({exc.msg})")
+
+    db.session.add(evaluation)
+    db.session.commit()
+    return evaluation.to_dict_api()
+
+
+@bp.route("/evaluation/<int:evaluation_id>/delete", methods=["POST"])
+@api_web_bp.route("/evaluation/<int:evaluation_id>/delete", methods=["POST"])
+@login_required
+@scodoc
+@permission_required(Permission.ScoEnsView)  # permission gérée dans la fonction
+@as_json
+def evaluation_delete(evaluation_id: int):
+    pass
diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py
index 3231cc88e..b9261c4fb 100644
--- a/app/but/bulletin_but.py
+++ b/app/but/bulletin_but.py
@@ -276,11 +276,10 @@ class BulletinBUT:
             "coef": fmt_note(e.coefficient)
             if e.evaluation_type == scu.EVALUATION_NORMALE
             else None,
-            "date": e.jour.isoformat() if e.jour else None,
+            "date_debut": e.date_debut.isoformat() if e.date_debut else None,
+            "date_fin": e.date_fin.isoformat() if e.date_fin else None,
             "description": e.description,
             "evaluation_type": e.evaluation_type,
-            "heure_debut": e.heure_debut.strftime("%H:%M") if e.heure_debut else None,
-            "heure_fin": e.heure_fin.strftime("%H:%M") if e.heure_debut else None,
             "note": {
                 "value": fmt_note(
                     eval_notes[etud.id],
@@ -298,6 +297,12 @@ class BulletinBUT:
             )
             if has_request_context()
             else "na",
+            # deprecated
+            "date": e.date_debut.isoformat() if e.date_debut else None,
+            "heure_debut": e.date_debut.time().isoformat("minutes")
+            if e.date_debut
+            else None,
+            "heure_fin": e.date_fin.time().isoformat("minutes") if e.date_fin else None,
         }
         return d
 
diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py
index 0bb1b9021..faf932e6f 100644
--- a/app/but/bulletin_but_xml_compat.py
+++ b/app/but/bulletin_but_xml_compat.py
@@ -202,12 +202,11 @@ def bulletin_but_xml_compat(
                             if e.visibulletin or version == "long":
                                 x_eval = Element(
                                     "evaluation",
-                                    jour=e.jour.isoformat() if e.jour else "",
-                                    heure_debut=e.heure_debut.isoformat()
-                                    if e.heure_debut
+                                    date_debut=e.date_debut.isoformat()
+                                    if e.date_debut
                                     else "",
-                                    heure_fin=e.heure_fin.isoformat()
-                                    if e.heure_debut
+                                    date_fin=e.date_fin.isoformat()
+                                    if e.date_debut
                                     else "",
                                     coefficient=str(e.coefficient),
                                     # pas les poids en XML compat
@@ -215,6 +214,16 @@ def bulletin_but_xml_compat(
                                     description=quote_xml_attr(e.description),
                                     # notes envoyées sur 20, ceci juste pour garder trace:
                                     note_max_origin=str(e.note_max),
+                                    # --- deprecated
+                                    jour=e.date_debut.isoformat()
+                                    if e.date_debut
+                                    else "",
+                                    heure_debut=e.date_debut.time().isoformat("minutes")
+                                    if e.date_debut
+                                    else "",
+                                    heure_fin=e.date_fin.time().isoformat("minutes")
+                                    if e.date_fin
+                                    else "",
                                 )
                                 x_mod.append(x_eval)
                                 try:
diff --git a/app/models/evaluations.py b/app/models/evaluations.py
index 987b51706..deb4bc263 100644
--- a/app/models/evaluations.py
+++ b/app/models/evaluations.py
@@ -5,17 +5,28 @@
 import datetime
 from operator import attrgetter
 
+from flask import g, url_for
+from flask_login import current_user
+import sqlalchemy as sa
+
 from app import db
 from app.models.etudiants import Identite
+from app.models.events import ScolarNews
 from app.models.moduleimpls import ModuleImpl
 from app.models.notes import NotesNotes
 from app.models.ues import UniteEns
 
-from app.scodoc.sco_exceptions import ScoValueError
+from app.scodoc import sco_cache
+from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
 import app.scodoc.notesdb as ndb
+import app.scodoc.sco_utils as scu
 
+MAX_EVALUATION_DURATION = datetime.timedelta(days=365)
+NOON = datetime.time(12, 00)
 DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
 
+VALID_EVALUATION_TYPES = {0, 1, 2}
+
 
 class Evaluation(db.Model):
     """Evaluation (contrôle, examen, ...)"""
@@ -27,9 +38,8 @@ class Evaluation(db.Model):
     moduleimpl_id = db.Column(
         db.Integer, db.ForeignKey("notes_moduleimpl.id"), index=True
     )
-    jour = db.Column(db.Date)
-    heure_debut = db.Column(db.Time)
-    heure_fin = db.Column(db.Time)
+    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)
@@ -50,47 +60,106 @@ class Evaluation(db.Model):
 
     def __repr__(self):
         return f"""<Evaluation {self.id} {
-            self.jour.isoformat() if self.jour else ''} "{
+            self.date_debut.isoformat() if self.date_debut else ''} "{
             self.description[:16] if self.description else ''}">"""
 
+    @classmethod
+    def create(
+        cls,
+        moduleimpl: ModuleImpl = None,
+        jour=None,
+        heure_debut=None,
+        heure_fin=None,
+        description=None,
+        note_max=None,
+        coefficient=None,
+        visibulletin=None,
+        publish_incomplete=None,
+        evaluation_type=None,
+        numero=None,
+        **kw,  # ceci pour absorber les éventuel arguments excedentaires
+    ):
+        """Create an evaluation. Check permission and all arguments."""
+        if not moduleimpl.can_edit_evaluation(current_user):
+            raise AccessDenied(
+                f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
+            )
+        args = locals()
+        del args["cls"]
+        del args["kw"]
+        check_convert_evaluation_args(moduleimpl, args)
+        # Check numeros
+        Evaluation.moduleimpl_evaluation_renumber(moduleimpl, only_if_unumbered=True)
+        if not "numero" in args or args["numero"] is None:
+            args["numero"] = cls.get_new_numero(moduleimpl, args["date_debut"])
+        #
+        evaluation = Evaluation(**args)
+        sco_cache.invalidate_formsemestre(formsemestre_id=moduleimpl.formsemestre_id)
+        url = url_for(
+            "notes.moduleimpl_status",
+            scodoc_dept=g.scodoc_dept,
+            moduleimpl_id=moduleimpl.id,
+        )
+        ScolarNews.add(
+            typ=ScolarNews.NEWS_NOTE,
+            obj=moduleimpl.id,
+            text=f"""Création d'une évaluation dans <a href="{url}">{
+                    moduleimpl.module.titre or '(module sans titre)'}</a>""",
+            url=url,
+        )
+        return evaluation
+
+    @classmethod
+    def get_new_numero(
+        cls, moduleimpl: ModuleImpl, date_debut: datetime.datetime
+    ) -> int:
+        """Get a new numero for an evaluation in this moduleimpl
+        If necessary, renumber existing evals to make room for a new one.
+        """
+        n = None
+        # Détermine le numero grâce à la date
+        # Liste des eval existantes triées par date, la plus ancienne en tete
+        evaluations = moduleimpl.evaluations.order_by(Evaluation.date_debut).all()
+        if date_debut is not None:
+            next_eval = None
+            t = date_debut
+            for e in evaluations:
+                if e.date_debut > t:
+                    next_eval = e
+                    break
+            if next_eval:
+                n = _moduleimpl_evaluation_insert_before(evaluations, next_eval)
+            else:
+                n = None  # à placer en fin
+        if n is None:  # pas de date ou en fin:
+            if evaluations:
+                n = evaluations[-1].numero + 1
+            else:
+                n = 0  # the only one
+        return n
+
     def to_dict(self) -> dict:
         "Représentation dict (riche, compat ScoDoc 7)"
         e = dict(self.__dict__)
         e.pop("_sa_instance_state", None)
         # ScoDoc7 output_formators
         e["evaluation_id"] = self.id
-        e["jour"] = e["jour"].strftime("%d/%m/%Y") if e["jour"] else ""
-        if self.jour is None:
-            e["date_debut"] = None
-            e["date_fin"] = None
-        else:
-            e["date_debut"] = datetime.datetime.combine(
-                self.jour, self.heure_debut or datetime.time(0, 0)
-            ).isoformat()
-            e["date_fin"] = datetime.datetime.combine(
-                self.jour, self.heure_fin or datetime.time(0, 0)
-            ).isoformat()
+        e["date_debut"] = e.date_debut.isoformat() if e.date_debut else None
+        e["date_fin"] = e.date_debut.isoformat() if e.date_fin else None
         e["numero"] = ndb.int_null_is_zero(e["numero"])
         e["poids"] = self.get_ue_poids_dict()  # { ue_id : poids }
+
+        # Deprecated
+        e["jour"] = e.date_debut.strftime("%d/%m/%Y") if e.date_debut else ""
+
         return evaluation_enrich_dict(e)
 
     def to_dict_api(self) -> dict:
         "Représentation dict pour API JSON"
-        if self.jour is None:
-            date_debut = None
-            date_fin = None
-        else:
-            date_debut = datetime.datetime.combine(
-                self.jour, self.heure_debut or datetime.time(0, 0)
-            ).isoformat()
-            date_fin = datetime.datetime.combine(
-                self.jour, self.heure_fin or datetime.time(0, 0)
-            ).isoformat()
-
         return {
             "coefficient": self.coefficient,
-            "date_debut": date_debut,
-            "date_fin": date_fin,
+            "date_debut": self.date_debut.isoformat(),
+            "date_fin": self.date_fin.isoformat(),
             "description": self.description,
             "evaluation_type": self.evaluation_type,
             "id": self.id,
@@ -104,11 +173,49 @@ class Evaluation(db.Model):
 
     def from_dict(self, data):
         """Set evaluation attributes from given dict values."""
-        check_evaluation_args(data)
+        check_convert_evaluation_args(self.moduleimpl, data)
+        if data.get("numero") is None:
+            data["numero"] = Evaluation.get_max_numero() + 1
         for k in self.__dict__.keys():
             if k != "_sa_instance_state" and k != "id" and k in data:
                 setattr(self, k, data[k])
 
+    @classmethod
+    def get_max_numero(cls, moduleimpl_id: int) -> int:
+        """Return max numero among evaluations in this
+        moduleimpl (0 if None)
+        """
+        max_num = (
+            db.session.query(sa.sql.functions.max(Evaluation.numero))
+            .filter_by(moduleimpl_id=moduleimpl_id)
+            .first()[0]
+        )
+        return max_num or 0
+
+    @classmethod
+    def moduleimpl_evaluation_renumber(
+        cls, moduleimpl: ModuleImpl, only_if_unumbered=False
+    ):
+        """Renumber evaluations in this moduleimpl, according to their date. (numero=0: oldest one)
+        Needed because previous versions of ScoDoc did not have eval numeros
+        Note: existing numeros are ignored
+        """
+        # Liste des eval existantes triées par date, la plus ancienne en tete
+        evaluations = moduleimpl.evaluations.order_by(
+            Evaluation.date_debut, Evaluation.numero
+        ).all()
+        all_numbered = all(e.numero is not None for e in evaluations)
+        if all_numbered and only_if_unumbered:
+            return  # all ok
+
+        # Reset all numeros:
+        i = 1
+        for e in evaluations:
+            e.numero = i
+            db.session.add(e)
+            i += 1
+        db.session.commit()
+
     def descr_heure(self) -> str:
         "Description de la plage horaire pour affichages"
         if self.heure_debut and (
@@ -146,19 +253,19 @@ class Evaluation(db.Model):
         return copy
 
     def is_matin(self) -> bool:
-        "Evaluation ayant lieu le matin (faux si pas de date)"
-        heure_debut_dt = self.heure_debut or datetime.time(8, 00)
-        # 8:00 au cas ou pas d'heure (note externe?)
-        return bool(self.jour) and heure_debut_dt < datetime.time(12, 00)
+        "Evaluation commençant le matin (faux si pas de date)"
+        if not self.date_debut:
+            return False
+        return self.date_debut.time() < NOON
 
     def is_apresmidi(self) -> bool:
-        "Evaluation ayant lieu l'après midi (faux si pas de date)"
-        heure_debut_dt = self.heure_debut or datetime.time(8, 00)
-        # 8:00 au cas ou pas d'heure (note externe?)
-        return bool(self.jour) and heure_debut_dt >= datetime.time(12, 00)
+        "Evaluation commençant l'après midi (faux si pas de date)"
+        if not self.date_debut:
+            return False
+        return self.date_debut.time() >= NOON
 
     def set_default_poids(self) -> bool:
-        """Initialize les poids bvers les UE à leurs valeurs par défaut
+        """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.
         Les poids existants ne sont pas modifiés.
         Return True if (uncommited) modification, False otherwise.
@@ -278,15 +385,13 @@ class EvaluationUEPoids(db.Model):
 def evaluation_enrich_dict(e: dict):
     """add or convert some fields in an evaluation dict"""
     # For ScoDoc7 compat
-    heure_debut_dt = e["heure_debut"] or datetime.time(
-        8, 00
-    )  # au cas ou pas d'heure (note externe?)
-    heure_fin_dt = e["heure_fin"] or datetime.time(8, 00)
-    e["heure_debut"] = ndb.TimefromISO8601(e["heure_debut"])
-    e["heure_fin"] = ndb.TimefromISO8601(e["heure_fin"])
-    e["jour_iso"] = ndb.DateDMYtoISO(e["jour"])
+    heure_debut_dt = e["date_debut"].time()
+    heure_fin_dt = e["date_fin"].time()
+    e["heure_debut"] = heure_debut_dt.strftime("%Hh%M")
+    e["heure_fin"] = heure_fin_dt.strftime("%Hh%M")
+    e["jour_iso"] = e["date_debut"].isoformat()  # XXX
     heure_debut, heure_fin = e["heure_debut"], e["heure_fin"]
-    d = ndb.TimeDuration(heure_debut, heure_fin)
+    d = _time_duration_HhM(heure_debut, heure_fin)
     if d is not None:
         m = d % 60
         e["duree"] = "%dh" % (d / 60)
@@ -313,49 +418,118 @@ def evaluation_enrich_dict(e: dict):
     return e
 
 
-def check_evaluation_args(args):
-    "Check coefficient, dates and duration, raises exception if invalid"
-    moduleimpl_id = args["moduleimpl_id"]
-    # check bareme
-    note_max = args.get("note_max", None)
-    if note_max is None:
-        raise ScoValueError("missing note_max")
+def check_convert_evaluation_args(moduleimpl: ModuleImpl, data: dict):
+    """Check coefficient, dates and duration, raises exception if invalid.
+    Convert date and time strings to date and time objects.
+
+    Set required default value for unspecified fields.
+    May raise ScoValueError.
+    """
+    # --- description
+    description = data.get("description", "")
+    if len(description) > scu.MAX_TEXT_LEN:
+        raise ScoValueError("description too large")
+    # --- evaluation_type
+    try:
+        data["evaluation_type"] = int(data.get("evaluation_type", 0) or 0)
+        if not data["evaluation_type"] in VALID_EVALUATION_TYPES:
+            raise ScoValueError("Invalid evaluation_type value")
+    except ValueError:
+        raise ScoValueError("Invalid evaluation_type value")
+
+    # --- note_max (bareme)
+    note_max = data.get("note_max", 20.0) or 20.0
     try:
         note_max = float(note_max)
     except ValueError:
         raise ScoValueError("Invalid note_max value")
     if note_max < 0:
         raise ScoValueError("Invalid note_max value (must be positive or null)")
-    # check coefficient
-    coef = args.get("coefficient", None)
-    if coef is None:
-        raise ScoValueError("missing coefficient")
+    data["note_max"] = note_max
+    # --- coefficient
+    coef = data.get("coefficient", 1.0) or 1.0
     try:
         coef = float(coef)
     except ValueError:
         raise ScoValueError("Invalid coefficient value")
     if coef < 0:
         raise ScoValueError("Invalid coefficient value (must be positive or null)")
-    # check date
-    jour = args.get("jour", None)
-    args["jour"] = jour
-    if jour:
-        modimpl = db.session.get(ModuleImpl, moduleimpl_id)
-        formsemestre = modimpl.formsemestre
-        y, m, d = [int(x) for x in ndb.DateDMYtoISO(jour).split("-")]
-        jour = datetime.date(y, m, d)
+    data["coefficient"] = coef
+    # --- jour (date de l'évaluation)
+    jour = data.get("jour", None)
+    if jour and not isinstance(jour, datetime.date):
+        if date_format == "dmy":
+            y, m, d = [int(x) for x in ndb.DateDMYtoISO(jour).split("-")]
+            jour = datetime.date(y, m, d)
+        else:  # ISO
+            jour = datetime.date.fromisoformat(jour)
+        formsemestre = moduleimpl.formsemestre
         if (jour > formsemestre.date_fin) or (jour < formsemestre.date_debut):
             raise ScoValueError(
-                "La date de l'évaluation (%s/%s/%s) n'est pas dans le semestre !"
-                % (d, m, y),
+                f"""La date de l'évaluation ({jour.strftime("%d/%m/%Y")}) n'est pas dans le semestre !""",
                 dest_url="javascript:history.back();",
             )
-    heure_debut = args.get("heure_debut", None)
-    args["heure_debut"] = heure_debut
-    heure_fin = args.get("heure_fin", None)
-    args["heure_fin"] = heure_fin
+    data["jour"] = jour
+    # --- heures
+    heure_debut = data.get("heure_debut", None)
+    if heure_debut and not isinstance(heure_debut, datetime.time):
+        if date_format == "dmy":
+            data["heure_debut"] = heure_to_time(heure_debut)
+        else:  # ISO
+            data["heure_debut"] = datetime.time.fromisoformat(heure_debut)
+    heure_fin = data.get("heure_fin", None)
+    if heure_fin and not isinstance(heure_fin, datetime.time):
+        if date_format == "dmy":
+            data["heure_fin"] = heure_to_time(heure_fin)
+        else:  # ISO
+            data["heure_fin"] = datetime.time.fromisoformat(heure_fin)
     if jour and ((not heure_debut) or (not heure_fin)):
         raise ScoValueError("Les heures doivent être précisées")
-    d = ndb.TimeDuration(heure_debut, heure_fin)
-    if d and ((d < 0) or (d > 60 * 12)):
-        raise ScoValueError("Heures de l'évaluation incohérentes !")
+    if heure_debut and heure_fin:
+        duration = ((data["heure_fin"].hour * 60) + data["heure_fin"].minute) - (
+            (data["heure_debut"].hour * 60) + data["heure_debut"].minute
+        )
+        if duration < 0 or duration > 60 * 12:
+            raise ScoValueError("Heures de l'évaluation incohérentes !")
+
+
+def heure_to_time(heure: str) -> datetime.time:
+    "Convert external heure ('10h22' or '10:22') to a time"
+    t = heure.strip().upper().replace("H", ":")
+    h, m = t.split(":")[:2]
+    return datetime.time(int(h), int(m))
+
+
+def _time_duration_HhM(heure_debut: str, heure_fin: str) -> int:
+    """duree (nb entier de minutes) entre deux heures a notre format
+    ie 12h23
+    """
+    if heure_debut and heure_fin:
+        h0, m0 = [int(x) for x in heure_debut.split("h")]
+        h1, m1 = [int(x) for x in heure_fin.split("h")]
+        d = (h1 - h0) * 60 + (m1 - m0)
+        return d
+    else:
+        return None
+
+
+def _moduleimpl_evaluation_insert_before(
+    evaluations: list[Evaluation], next_eval: Evaluation
+) -> int:
+    """Renumber evaluations such that an evaluation with can be inserted before next_eval
+    Returns numero suitable for the inserted evaluation
+    """
+    if next_eval:
+        n = next_eval.numero
+        if n is None:
+            Evaluation.moduleimpl_evaluation_renumber(next_eval.moduleimpl)
+            n = next_eval.numero
+    else:
+        n = 1
+    # all numeros >= n are incremented
+    for e in evaluations:
+        if e.numero >= n:
+            e.numero += 1
+            db.session.add(e)
+    db.session.commit()
+    return n
diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py
index c4d7c3fe0..e84fe82b3 100644
--- a/app/models/moduleimpls.py
+++ b/app/models/moduleimpls.py
@@ -101,6 +101,23 @@ class ModuleImpl(db.Model):
             d.pop("module", None)
         return d
 
+    def can_edit_evaluation(self, user) -> bool:
+        """True if this user can create, delete or edit and evaluation in this modimpl
+        (nb: n'implique pas le droit de saisir ou modifier des notes)
+        """
+        # acces pour resp. moduleimpl et resp. form semestre (dir etud)
+        if (
+            user.has_permission(Permission.ScoEditAllEvals)
+            or user.id == self.responsable_id
+            or user.id in (r.id for r in self.formsemestre.responsables)
+        ):
+            return True
+        elif self.formsemestre.ens_can_edit_eval:
+            if user.id in (e.id for e in self.enseignants):
+                return True
+
+        return False
+
     def can_change_ens_by(self, user: User, raise_exc=False) -> bool:
         """Check if user can modify module resp.
         If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not.
diff --git a/app/scodoc/notesdb.py b/app/scodoc/notesdb.py
index 4d9b6aa2a..3b7dd2654 100644
--- a/app/scodoc/notesdb.py
+++ b/app/scodoc/notesdb.py
@@ -459,8 +459,10 @@ def dictfilter(d, fields, filter_nulls=True):
 # --- Misc Tools
 
 
-def DateDMYtoISO(dmy: str, null_is_empty=False) -> str:
-    "convert date string from french format to ISO"
+def DateDMYtoISO(dmy: str, null_is_empty=False) -> str:  # XXX deprecated
+    """Convert date string from french format to ISO.
+    If null_is_empty (default false), returns "" if no input.
+    """
     if not dmy:
         if null_is_empty:
             return ""
@@ -506,7 +508,7 @@ def DateISOtoDMY(isodate):
     return "%02d/%02d/%04d" % (day, month, year)
 
 
-def TimetoISO8601(t, null_is_empty=False):
+def TimetoISO8601(t, null_is_empty=False) -> str:
     "convert time string to ISO 8601 (allow 16:03, 16h03, 16)"
     if isinstance(t, datetime.time):
         return t.isoformat()
@@ -518,7 +520,7 @@ def TimetoISO8601(t, null_is_empty=False):
     return t
 
 
-def TimefromISO8601(t):
+def TimefromISO8601(t) -> str:
     "convert time string from ISO 8601 to our display format"
     if not t:
         return t
@@ -532,19 +534,6 @@ def TimefromISO8601(t):
     return fs[0] + "h" + fs[1]  # discard seconds
 
 
-def TimeDuration(heure_debut, heure_fin):
-    """duree (nb entier de minutes) entre deux heures a notre format
-    ie 12h23
-    """
-    if heure_debut and heure_fin:
-        h0, m0 = [int(x) for x in heure_debut.split("h")]
-        h1, m1 = [int(x) for x in heure_fin.split("h")]
-        d = (h1 - h0) * 60 + (m1 - m0)
-        return d
-    else:
-        return None
-
-
 def float_null_is_zero(x):
     if x is None or x == "":
         return 0.0
diff --git a/app/scodoc/sco_bulletins_json.py b/app/scodoc/sco_bulletins_json.py
index 72661afc8..74bc29107 100644
--- a/app/scodoc/sco_bulletins_json.py
+++ b/app/scodoc/sco_bulletins_json.py
@@ -37,7 +37,7 @@ from app import db, ScoDocJSONEncoder
 from app.comp import res_sem
 from app.comp.res_compat import NotesTableCompat
 from app.models import but_validations
-from app.models import Matiere, ModuleImpl, UniteEns
+from app.models import Evaluation, Matiere, ModuleImpl, UniteEns
 from app.models.etudiants import Identite
 from app.models.formsemestre import FormSemestre
 
@@ -324,7 +324,7 @@ def formsemestre_bulletinetud_published_dict(
 def _list_modimpls(
     nt: NotesTableCompat,
     etudid: int,
-    modimpls: list[ModuleImpl],
+    modimpls: list[dict],
     prefs: SemPreferences,
     version: str,
 ) -> list[dict]:
@@ -398,24 +398,29 @@ def _list_modimpls(
             # Evaluations incomplètes ou futures:
             complete_eval_ids = set([e["evaluation_id"] for e in evals])
             if prefs["bul_show_all_evals"]:
-                all_evals = sco_evaluation_db.do_evaluation_list(
-                    args={"moduleimpl_id": modimpl["moduleimpl_id"]}
-                )
-                all_evals.reverse()  # plus ancienne d'abord
-                for e in all_evals:
-                    if e["evaluation_id"] not in complete_eval_ids:
+                evaluations = Evaluation.query.filter_by(
+                    moduleimpl_id=modimpl["moduleimpl_id"]
+                ).order_by(Evaluation.date_debut)
+                # plus ancienne d'abord
+                for e in evaluations:
+                    if e.id not in complete_eval_ids:
                         mod_dict["evaluation"].append(
                             dict(
-                                jour=ndb.DateDMYtoISO(e["jour"], null_is_empty=True),
+                                date_debut=e.date_debut.isoformat()
+                                if e.date_debut
+                                else None,
+                                date_fin=e.date_fin.isoformat() if e.date_fin else None,
+                                coefficient=e.coefficient,
+                                description=quote_xml_attr(e.description or ""),
+                                incomplete="1",
+                                # Deprecated:
+                                jour=e.date_debut.isoformat() if e.date_debut else "",
                                 heure_debut=ndb.TimetoISO8601(
                                     e["heure_debut"], null_is_empty=True
                                 ),
                                 heure_fin=ndb.TimetoISO8601(
                                     e["heure_fin"], null_is_empty=True
                                 ),
-                                coefficient=e["coefficient"],
-                                description=quote_xml_attr(e["description"]),
-                                incomplete="1",
                             )
                         )
         modules_dict.append(mod_dict)
diff --git a/app/scodoc/sco_bulletins_xml.py b/app/scodoc/sco_bulletins_xml.py
index 178b02964..ca32ec46a 100644
--- a/app/scodoc/sco_bulletins_xml.py
+++ b/app/scodoc/sco_bulletins_xml.py
@@ -50,11 +50,11 @@ import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
 from app import log
 from app.but.bulletin_but_xml_compat import bulletin_but_xml_compat
+from app.models.evaluations import Evaluation
 from app.models.formsemestre import FormSemestre
 from app.scodoc import sco_assiduites
 from app.scodoc import codes_cursus
 from app.scodoc import sco_edit_ue
-from app.scodoc import sco_evaluation_db
 from app.scodoc import sco_formsemestre
 from app.scodoc import sco_groups
 from app.scodoc import sco_photos
@@ -320,12 +320,11 @@ def make_xml_formsemestre_bulletinetud(
                 if sco_preferences.get_preference(
                     "bul_show_all_evals", formsemestre_id
                 ):
-                    all_evals = sco_evaluation_db.do_evaluation_list(
-                        args={"moduleimpl_id": modimpl["moduleimpl_id"]}
-                    )
-                    all_evals.reverse()  # plus ancienne d'abord
-                    for e in all_evals:
-                        if e["evaluation_id"] not in complete_eval_ids:
+                    evaluations = Evaluation.query.filter_by(
+                        moduleimpl_id=modimpl["moduleimpl_id"]
+                    ).order_by(Evaluation.date_debut)
+                    for e in evaluations:
+                        if e.id not in complete_eval_ids:
                             x_eval = Element(
                                 "evaluation",
                                 jour=ndb.DateDMYtoISO(e["jour"], null_is_empty=True),
diff --git a/app/scodoc/sco_evaluation_db.py b/app/scodoc/sco_evaluation_db.py
index 260481b99..f0af65eac 100644
--- a/app/scodoc/sco_evaluation_db.py
+++ b/app/scodoc/sco_evaluation_db.py
@@ -37,7 +37,7 @@ from flask_login import current_user
 from app import db, log
 
 from app.models import Evaluation, ModuleImpl, ScolarNews
-from app.models.evaluations import evaluation_enrich_dict, check_evaluation_args
+from app.models.evaluations import evaluation_enrich_dict, check_convert_evaluation_args
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
 from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
@@ -89,7 +89,6 @@ def do_evaluation_list(args, sortkey=None):
     'apresmidi' : 1 (termine après 12:00) ou 0
     'descrheure' : ' de 15h00 à 16h30'
     """
-    # Attention: transformation fonction ScoDoc7 en SQLAlchemy
     cnx = ndb.GetDBConnexion()
     evals = _evaluationEditor.list(cnx, args, sortkey=sortkey)
     # calcule duree (chaine de car.) de chaque evaluation et ajoute jour_iso, matin, apresmidi
@@ -108,115 +107,33 @@ def do_evaluation_list_in_formsemestre(formsemestre_id):
     return evals
 
 
-def do_evaluation_create(
-    moduleimpl_id=None,
-    jour=None,
-    heure_debut=None,
-    heure_fin=None,
-    description=None,
-    note_max=None,
-    coefficient=None,
-    visibulletin=None,
-    publish_incomplete=None,
-    evaluation_type=None,
-    numero=None,
-    **kw,  # ceci pour absorber les arguments excedentaires de tf #sco8
-):
-    """Create an evaluation"""
-    if not sco_permissions_check.can_edit_evaluation(moduleimpl_id=moduleimpl_id):
-        raise AccessDenied(
-            f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
-        )
-    args = locals()
-    log("do_evaluation_create: args=" + str(args))
-    modimpl: ModuleImpl = db.session.get(ModuleImpl, moduleimpl_id)
-    if modimpl is None:
-        raise ValueError("module not found")
-    check_evaluation_args(args)
-    # Check numeros
-    moduleimpl_evaluation_renumber(moduleimpl_id, only_if_unumbered=True)
-    if not "numero" in args or args["numero"] is None:
-        n = None
-        # determine le numero avec la date
-        # Liste des eval existantes triees par date, la plus ancienne en tete
-        mod_evals = do_evaluation_list(
-            args={"moduleimpl_id": moduleimpl_id},
-            sortkey="jour asc, heure_debut asc",
-        )
-        if args["jour"]:
-            next_eval = None
-            t = (
-                ndb.DateDMYtoISO(args["jour"], null_is_empty=True),
-                ndb.TimetoISO8601(args["heure_debut"], null_is_empty=True),
-            )
-            for e in mod_evals:
-                if (
-                    ndb.DateDMYtoISO(e["jour"], null_is_empty=True),
-                    ndb.TimetoISO8601(e["heure_debut"], null_is_empty=True),
-                ) > t:
-                    next_eval = e
-                    break
-            if next_eval:
-                n = moduleimpl_evaluation_insert_before(mod_evals, next_eval)
-            else:
-                n = None  # a placer en fin
-        if n is None:  # pas de date ou en fin:
-            if mod_evals:
-                log(pprint.pformat(mod_evals[-1]))
-                n = mod_evals[-1]["numero"] + 1
-            else:
-                n = 0  # the only one
-        # log("creating with numero n=%d" % n)
-        args["numero"] = n
-
-    #
-    cnx = ndb.GetDBConnexion()
-    r = _evaluationEditor.create(cnx, args)
-
-    # news
-    sco_cache.invalidate_formsemestre(formsemestre_id=modimpl.formsemestre_id)
-    url = url_for(
-        "notes.moduleimpl_status",
-        scodoc_dept=g.scodoc_dept,
-        moduleimpl_id=moduleimpl_id,
-    )
-    ScolarNews.add(
-        typ=ScolarNews.NEWS_NOTE,
-        obj=moduleimpl_id,
-        text=f"""Création d'une évaluation dans <a href="{url}">{
-                modimpl.module.titre or '(module sans titre)'}</a>""",
-        url=url,
-    )
-
-    return r
-
-
 def do_evaluation_edit(args):
     "edit an evaluation"
     evaluation_id = args["evaluation_id"]
-    the_evals = do_evaluation_list({"evaluation_id": evaluation_id})
-    if not the_evals:
+    evaluation: Evaluation = db.session.get(Evaluation, evaluation_id)
+    if evaluation is None:
         raise ValueError("evaluation inexistante !")
-    moduleimpl_id = the_evals[0]["moduleimpl_id"]
-    if not sco_permissions_check.can_edit_evaluation(moduleimpl_id=moduleimpl_id):
+
+    if not evaluation.moduleimpl.can_edit_evaluation(current_user):
         raise AccessDenied(
-            "Modification évaluation impossible pour %s" % current_user.get_nomplogin()
+            f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
         )
-    args["moduleimpl_id"] = moduleimpl_id
-    check_evaluation_args(args)
+    args["moduleimpl_id"] = evaluation.moduleimpl.id
+    check_convert_evaluation_args(evaluation.moduleimpl, args)
 
     cnx = ndb.GetDBConnexion()
     _evaluationEditor.edit(cnx, args)
     # inval cache pour ce semestre
-    M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0]
-    sco_cache.invalidate_formsemestre(formsemestre_id=M["formsemestre_id"])
+    sco_cache.invalidate_formsemestre(
+        formsemestre_id=evaluation.moduleimpl.formsemestre_id
+    )
 
 
 def do_evaluation_delete(evaluation_id):
     "delete evaluation"
     evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
     modimpl: ModuleImpl = evaluation.moduleimpl
-    if not sco_permissions_check.can_edit_evaluation(moduleimpl_id=modimpl.id):
+    if not modimpl.can_edit_evaluation(current_user):
         raise AccessDenied(
             f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
         )
@@ -287,68 +204,6 @@ def do_evaluation_get_all_notes(
     return d
 
 
-def moduleimpl_evaluation_renumber(moduleimpl_id, only_if_unumbered=False, redirect=0):
-    """Renumber evaluations in this module, according to their date. (numero=0: oldest one)
-    Needed because previous versions of ScoDoc did not have eval numeros
-    Note: existing numeros are ignored
-    """
-    redirect = int(redirect)
-    # log('moduleimpl_evaluation_renumber( moduleimpl_id=%s )' % moduleimpl_id )
-    # List sorted according to date/heure, ignoring numeros:
-    # (note that we place  evaluations with NULL date at the end)
-    mod_evals = do_evaluation_list(
-        args={"moduleimpl_id": moduleimpl_id},
-        sortkey="jour asc, heure_debut asc",
-    )
-
-    all_numbered = False not in [x["numero"] > 0 for x in mod_evals]
-    if all_numbered and only_if_unumbered:
-        return  # all ok
-
-    # Reset all numeros:
-    i = 1
-    for e in mod_evals:
-        e["numero"] = i
-        do_evaluation_edit(e)
-        i += 1
-
-    # If requested, redirect to moduleimpl page:
-    if redirect:
-        return flask.redirect(
-            url_for(
-                "notes.moduleimpl_status",
-                scodoc_dept=g.scodoc_dept,
-                moduleimpl_id=moduleimpl_id,
-            )
-        )
-
-
-def moduleimpl_evaluation_insert_before(mod_evals, next_eval):
-    """Renumber evals such that an evaluation with can be inserted before next_eval
-    Returns numero suitable for the inserted evaluation
-    """
-    if next_eval:
-        n = next_eval["numero"]
-        if not n:
-            log("renumbering old evals")
-            moduleimpl_evaluation_renumber(next_eval["moduleimpl_id"])
-            next_eval = do_evaluation_list(
-                args={"evaluation_id": next_eval["evaluation_id"]}
-            )[0]
-            n = next_eval["numero"]
-    else:
-        n = 1
-    # log('inserting at position numero %s' % n )
-    # all numeros >= n are incremented
-    for e in mod_evals:
-        if e["numero"] >= n:
-            e["numero"] += 1
-            # log('incrementing %s to %s' % (e['evaluation_id'], e['numero']))
-            do_evaluation_edit(e)
-
-    return n
-
-
 def moduleimpl_evaluation_move(evaluation_id: int, after=0, redirect=1):
     """Move before/after previous one (decrement/increment numero)
     (published)
@@ -357,12 +212,13 @@ def moduleimpl_evaluation_move(evaluation_id: int, after=0, redirect=1):
     moduleimpl_id = evaluation.moduleimpl_id
     redirect = int(redirect)
     # access: can change eval ?
-    if not sco_permissions_check.can_edit_evaluation(moduleimpl_id=moduleimpl_id):
+    if not evaluation.moduleimpl.can_edit_evaluation(current_user):
         raise AccessDenied(
             f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
         )
-
-    moduleimpl_evaluation_renumber(moduleimpl_id, only_if_unumbered=True)
+    Evaluation.moduleimpl_evaluation_renumber(
+        evaluation.moduleimpl, only_if_unumbered=True
+    )
     e = do_evaluation_list(args={"evaluation_id": evaluation_id})[0]
 
     after = int(after)  # 0: deplace avant, 1 deplace apres
@@ -379,8 +235,8 @@ def moduleimpl_evaluation_move(evaluation_id: int, after=0, redirect=1):
         if neigh:  #
             if neigh["numero"] == e["numero"]:
                 log("Warning: moduleimpl_evaluation_move: forcing renumber")
-                moduleimpl_evaluation_renumber(
-                    e["moduleimpl_id"], only_if_unumbered=False
+                Evaluation.moduleimpl_evaluation_renumber(
+                    evaluation.moduleimpl, only_if_unumbered=False
                 )
             else:
                 # swap numero with neighbor
diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py
index ac9f6e2fa..f40a30e94 100644
--- a/app/scodoc/sco_evaluation_edit.py
+++ b/app/scodoc/sco_evaluation_edit.py
@@ -83,7 +83,7 @@ def evaluation_create_form(
     can_edit_poids = not preferences["but_disable_edit_poids_evaluations"]
     min_note_max = scu.NOTES_PRECISION  # le plus petit bareme possible
     #
-    if not sco_permissions_check.can_edit_evaluation(moduleimpl_id=moduleimpl_id):
+    if not modimpl.can_edit_evaluation(current_user):
         return f"""
         {html_sco_header.sco_header()}
         <h2>Opération non autorisée</h2>
@@ -356,8 +356,11 @@ def evaluation_create_form(
         if edit:
             sco_evaluation_db.do_evaluation_edit(tf[2])
         else:
-            # création d'une evaluation (via fonction ScoDoc7)
-            evaluation_id = sco_evaluation_db.do_evaluation_create(**tf[2])
+            # création d'une evaluation
+            evaluation = Evaluation.create(moduleimpl=modimpl, **tf[2])
+            db.session.add(evaluation)
+            db.session.commit()
+            evaluation_id = evaluation.id
         if is_apc:
             # Set poids
             evaluation = db.session.get(Evaluation, evaluation_id)
diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py
index 84e5766ae..3f167cf02 100644
--- a/app/scodoc/sco_moduleimpl_status.py
+++ b/app/scodoc/sco_moduleimpl_status.py
@@ -435,8 +435,7 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
             top_table_links += f"""
             <a class="stdlink" style="margin-left:2em;" href="{
                 url_for("notes.moduleimpl_evaluation_renumber", 
-                scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id,
-                redirect=1)
+                scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id)
             }">Trier par date</a>
             """
     if nb_evaluations > 0:
diff --git a/app/scodoc/sco_permissions_check.py b/app/scodoc/sco_permissions_check.py
index 3ff5d3a3d..495a1eb4b 100644
--- a/app/scodoc/sco_permissions_check.py
+++ b/app/scodoc/sco_permissions_check.py
@@ -54,34 +54,6 @@ def can_edit_notes(authuser, moduleimpl_id, allow_ens=True):
             return True
 
 
-def can_edit_evaluation(moduleimpl_id=None):
-    """Vérifie que l'on a le droit de modifier, créer ou détruire une
-    évaluation dans ce module.
-    Sinon, lance une exception.
-    (nb: n'implique pas le droit de saisir ou modifier des notes)
-    """
-    from app.scodoc import sco_formsemestre
-
-    # acces pour resp. moduleimpl et resp. form semestre (dir etud)
-    if moduleimpl_id is None:
-        raise ValueError("no moduleimpl specified")  # bug
-    M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0]
-    sem = sco_formsemestre.get_formsemestre(M["formsemestre_id"])
-
-    if (
-        current_user.has_permission(Permission.ScoEditAllEvals)
-        or current_user.id == M["responsable_id"]
-        or current_user.id in sem["responsables"]
-    ):
-        return True
-    elif sem["ens_can_edit_eval"]:
-        for ens in M["ens"]:
-            if ens["ens_id"] == current_user.id:
-                return True
-
-    return False
-
-
 def can_suppress_annotation(annotation_id):
     """True if current user can suppress this annotation
     Seuls l'auteur de l'annotation et le chef de dept peuvent supprimer
diff --git a/app/scodoc/sco_ue_external.py b/app/scodoc/sco_ue_external.py
index 22471d3c4..07177a662 100644
--- a/app/scodoc/sco_ue_external.py
+++ b/app/scodoc/sco_ue_external.py
@@ -60,7 +60,7 @@ from app.models.formsemestre import FormSemestre
 
 
 from app import db, log
-from app.models import UniteEns
+from app.models import Evaluation, ModuleImpl, UniteEns
 from app.scodoc import html_sco_header
 from app.scodoc import codes_cursus
 from app.scodoc import sco_edit_matiere
@@ -154,6 +154,7 @@ def external_ue_inscrit_et_note(
     """Inscrit les étudiants au moduleimpl, crée au besoin une évaluation
     et enregistre les notes.
     """
+    moduleimpl: ModuleImpl = db.session.get(ModuleImpl, moduleimpl_id)
     log(
         f"external_ue_inscrit_et_note(moduleimpl_id={moduleimpl_id}, notes_etuds={notes_etuds})"
     )
@@ -163,18 +164,14 @@ def external_ue_inscrit_et_note(
         formsemestre_id,
         list(notes_etuds.keys()),
     )
-
     # Création d'une évaluation si il n'y en a pas déjà:
-    mod_evals = sco_evaluation_db.do_evaluation_list(
-        args={"moduleimpl_id": moduleimpl_id}
-    )
-    if len(mod_evals):
+    if moduleimpl.evaluations.count() > 0:
         # met la note dans le première évaluation existante:
-        evaluation_id = mod_evals[0]["evaluation_id"]
+        evaluation: Evaluation = moduleimpl.evaluations.first()
     else:
         # crée une évaluation:
-        evaluation_id = sco_evaluation_db.do_evaluation_create(
-            moduleimpl_id=moduleimpl_id,
+        evaluation: Evaluation = Evaluation.create(
+            moduleimpl=moduleimpl,
             note_max=20.0,
             coefficient=1.0,
             publish_incomplete=True,
@@ -185,7 +182,7 @@ def external_ue_inscrit_et_note(
     # Saisie des notes
     _, _, _ = sco_saisie_notes.notes_add(
         current_user,
-        evaluation_id,
+        evaluation.id,
         list(notes_etuds.items()),
         do_it=True,
     )
diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py
index 6208c16e1..25645cebd 100644
--- a/app/scodoc/sco_utils.py
+++ b/app/scodoc/sco_utils.py
@@ -68,6 +68,9 @@ from app.scodoc.codes_cursus import NOTES_TOLERANCE, CODES_EXPL
 from app.scodoc import sco_xml
 import sco_version
 
+# En principe, aucun champ text ne devrait excéder cette taille
+MAX_TEXT_LEN = 64 * 1024
+
 # le répertoire static, lié à chaque release pour éviter les problèmes de caches
 STATIC_DIR = (
     os.environ.get("SCRIPT_NAME", "") + "/ScoDoc/static/links/" + sco_version.SCOVERSION
diff --git a/app/views/notes.py b/app/views/notes.py
index da80388a3..9f344575b 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -57,8 +57,8 @@ from app.but.forms import jury_but_forms
 from app.comp import jury, res_sem
 from app.comp.res_compat import NotesTableCompat
 from app.models import (
+    Evaluation,
     Formation,
-    ScolarFormSemestreValidation,
     ScolarAutorisationInscription,
     ScolarNews,
     Scolog,
@@ -134,6 +134,7 @@ 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
@@ -378,11 +379,40 @@ sco_publish(
     sco_evaluations.formsemestre_evaluations_delai_correction,
     Permission.ScoView,
 )
-sco_publish(
-    "/moduleimpl_evaluation_renumber",
-    sco_evaluation_db.moduleimpl_evaluation_renumber,
-    Permission.ScoView,
-)
+
+
+@bp.route("/moduleimpl_evaluation_renumber", methods=["GET", "POST"])
+@scodoc
+@permission_required_compat_scodoc7(Permission.ScoView)
+@scodoc7func
+def moduleimpl_evaluation_renumber(moduleimpl_id):
+    "Renumérote les évaluations, triant par date"
+    modimpl: ModuleImpl = (
+        ModuleImpl.query.filter_by(id=moduleimpl_id)
+        .join(FormSemestre)
+        .filter_by(dept_id=g.scodoc_dept_id)
+        .first_or_404()
+    )
+    if not modimpl.can_edit_evaluation(current_user):
+        raise ScoPermissionDenied(
+            dest_url=url_for(
+                "notes.moduleimpl_status",
+                scodoc_dept=g.scodoc_dept,
+                moduleimpl_id=modimpl.id,
+            )
+        )
+    Evaluation.moduleimpl_evaluation_renumber(modimpl)
+    # redirect to moduleimpl page:
+    if redirect:
+        return flask.redirect(
+            url_for(
+                "notes.moduleimpl_status",
+                scodoc_dept=g.scodoc_dept,
+                moduleimpl_id=moduleimpl_id,
+            )
+        )
+
+
 sco_publish(
     "/moduleimpl_evaluation_move",
     sco_evaluation_db.moduleimpl_evaluation_move,
diff --git a/migrations/versions/5c44d0d215ca_evaluation_date.py b/migrations/versions/5c44d0d215ca_evaluation_date.py
new file mode 100644
index 000000000..66ab3617c
--- /dev/null
+++ b/migrations/versions/5c44d0d215ca_evaluation_date.py
@@ -0,0 +1,58 @@
+"""evaluation date: modifie le codage des dates d'évaluations
+
+Revision ID: 5c44d0d215ca
+Revises: 45e0a855b8eb
+Create Date: 2023-08-22 14:39:23.831483
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = "5c44d0d215ca"
+down_revision = "45e0a855b8eb"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    "modifie les colonnes codant les dates d'évaluations"
+    with op.batch_alter_table("notes_evaluation", schema=None) as batch_op:
+        batch_op.add_column(
+            sa.Column("date_debut", sa.DateTime(timezone=True), nullable=True)
+        )
+        batch_op.add_column(
+            sa.Column("date_fin", sa.DateTime(timezone=True), nullable=True)
+        )
+    # recode les dates existantes
+    op.execute("UPDATE notes_evaluation SET date_debut = jour+heure_debut;")
+    op.execute("UPDATE notes_evaluation SET date_fin = jour+heure_fin;")
+    with op.batch_alter_table("notes_evaluation", schema=None) as batch_op:
+        batch_op.drop_column("jour")
+        batch_op.drop_column("heure_fin")
+        batch_op.drop_column("heure_debut")
+
+
+def downgrade():
+    "modifie les colonnes codant les dates d'évaluations"
+    with op.batch_alter_table("notes_evaluation", schema=None) as batch_op:
+        batch_op.add_column(
+            sa.Column(
+                "heure_debut", postgresql.TIME(), autoincrement=False, nullable=True
+            )
+        )
+        batch_op.add_column(
+            sa.Column(
+                "heure_fin", postgresql.TIME(), autoincrement=False, nullable=True
+            )
+        )
+        batch_op.add_column(
+            sa.Column("jour", sa.DATE(), autoincrement=False, nullable=True)
+        )
+    op.execute("UPDATE notes_evaluation SET jour = DATE(date_debut);")
+    op.execute("UPDATE notes_evaluation SET heure_debut = date_debut::time;")
+    op.execute("UPDATE notes_evaluation SET heure_fin = date_fin::time;")
+    with op.batch_alter_table("notes_evaluation", schema=None) as batch_op:
+        batch_op.drop_column("date_fin")
+        batch_op.drop_column("date_debut")
diff --git a/scodoc.py b/scodoc.py
index aef11ae0a..5410dbdad 100755
--- a/scodoc.py
+++ b/scodoc.py
@@ -5,7 +5,7 @@
 
 
 """
-
+import datetime
 from pprint import pprint as pp
 import re
 import sys
@@ -82,6 +82,7 @@ def make_shell_context():
         "ctx": app.test_request_context(),
         "current_app": flask.current_app,
         "current_user": current_user,
+        "datetime": datetime,
         "Departement": Departement,
         "db": db,
         "Evaluation": Evaluation,
diff --git a/tests/api/exemple-api-basic.py b/tests/api/exemple-api-basic.py
index eb7ae3b3a..5433fadcf 100644
--- a/tests/api/exemple-api-basic.py
+++ b/tests/api/exemple-api-basic.py
@@ -315,13 +315,12 @@ pp(GET(f"/formsemestre/880/resultats", headers=HEADERS)[0])
 # jour = sem["date_fin"]
 # evaluation_id = POST(
 #     s,
-#     "/Notes/do_evaluation_create",
+#     f"/moduleimpl/{mod['moduleimpl_id']}/evaluation/create",
 #     data={
-#         "moduleimpl_id": mod["moduleimpl_id"],
 #         "coefficient": 1,
-#         "jour": jour,  # "5/9/2019",
-#         "heure_debut": "9h00",
-#         "heure_fin": "10h00",
+#         "jour": jour,  # "2023-08-23",
+#         "heure_debut": "9:00",
+#         "heure_fin": "10:00",
 #         "note_max": 20,  # notes sur 20
 #         "description": "essai",
 #     },
diff --git a/tests/api/exemple-api-scodoc7.py b/tests/api/exemple-api-scodoc7.py
index 02e4cfb0f..fc80d13e5 100644
--- a/tests/api/exemple-api-scodoc7.py
+++ b/tests/api/exemple-api-scodoc7.py
@@ -165,37 +165,3 @@ assert isinstance(json.loads(r.text)[0]["billet_id"], int)
 # print(f"{len(inscrits)} inscrits dans ce module")
 # # prend le premier inscrit, au hasard:
 # etudid = inscrits[0]["etudid"]
-
-# # ---- Création d'une evaluation le dernier jour du semestre
-# jour = sem["date_fin"]
-# evaluation_id = POST(
-#     "/Notes/do_evaluation_create",
-#     data={
-#         "moduleimpl_id": mod["moduleimpl_id"],
-#         "coefficient": 1,
-#         "jour": jour,  # "5/9/2019",
-#         "heure_debut": "9h00",
-#         "heure_fin": "10h00",
-#         "note_max": 20,  # notes sur 20
-#         "description": "essai",
-#     },
-#     errmsg="échec création évaluation",
-# )
-
-# print(
-#     f"Evaluation créée dans le module {mod['moduleimpl_id']}, evaluation_id={evaluation_id}"
-# )
-# print(
-#     f"Pour vérifier, aller sur: {DEPT_URL}/Notes/moduleimpl_status?moduleimpl_id={mod['moduleimpl_id']}",
-# )
-
-# # ---- Saisie d'une note
-# junk = POST(
-#     "/Notes/save_note",
-#     data={
-#         "etudid": etudid,
-#         "evaluation_id": evaluation_id,
-#         "value": 16.66,  # la note !
-#         "comment": "test API",
-#     },
-# )
diff --git a/tests/api/test_api_evaluations.py b/tests/api/test_api_evaluations.py
index 9e1de1e23..e069cf4e8 100644
--- a/tests/api/test_api_evaluations.py
+++ b/tests/api/test_api_evaluations.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 
-"""Test Logos
+"""Test APi evaluations
 
 Utilisation :
     créer les variables d'environnement: (indiquer les valeurs
@@ -20,7 +20,13 @@ Utilisation :
 import requests
 
 from app.scodoc import sco_utils as scu
-from tests.api.setup_test_api import API_URL, CHECK_CERTIFICATE, api_headers
+from tests.api.setup_test_api import (
+    API_URL,
+    CHECK_CERTIFICATE,
+    POST_JSON,
+    api_admin_headers,
+    api_headers,
+)
 from tests.api.tools_test_api import (
     verify_fields,
     EVALUATIONS_FIELDS,
@@ -43,25 +49,25 @@ def test_evaluations(api_headers):
         timeout=scu.SCO_TEST_API_TIMEOUT,
     )
     assert r.status_code == 200
-    list_eval = r.json()
-    assert list_eval
-    assert isinstance(list_eval, list)
-    for eval in list_eval:
-        assert verify_fields(eval, EVALUATIONS_FIELDS) is True
-        assert isinstance(eval["id"], int)
-        assert isinstance(eval["note_max"], float)
-        assert isinstance(eval["visi_bulletin"], bool)
-        assert isinstance(eval["evaluation_type"], int)
-        assert isinstance(eval["moduleimpl_id"], int)
-        assert eval["description"] is None or isinstance(eval["description"], str)
-        assert isinstance(eval["coefficient"], float)
-        assert isinstance(eval["publish_incomplete"], bool)
-        assert isinstance(eval["numero"], int)
-        assert eval["date_debut"] is None or isinstance(eval["date_debut"], str)
-        assert eval["date_fin"] is None or isinstance(eval["date_fin"], str)
-        assert isinstance(eval["poids"], dict)
-
-        assert eval["moduleimpl_id"] == moduleimpl_id
+    evaluations = r.json()
+    assert evaluations
+    assert isinstance(evaluations, list)
+    for e in evaluations:
+        assert verify_fields(e, EVALUATIONS_FIELDS)
+        assert isinstance(e["id"], int)
+        assert isinstance(e["note_max"], float)
+        assert isinstance(e["visi_bulletin"], bool)
+        assert isinstance(e["evaluation_type"], int)
+        assert isinstance(e["moduleimpl_id"], int)
+        assert e["description"] is None or isinstance(e["description"], str)
+        assert isinstance(e["coefficient"], float)
+        assert isinstance(e["publish_incomplete"], bool)
+        assert isinstance(e["numero"], int)
+        assert e["date_debut"] is None or isinstance(e["date_debut"], str)
+        assert e["date_fin"] is None or isinstance(e["date_fin"], str)
+        assert isinstance(e["poids"], dict)
+
+        assert e["moduleimpl_id"] == moduleimpl_id
 
 
 def test_evaluation_notes(api_headers):
@@ -92,3 +98,31 @@ def test_evaluation_notes(api_headers):
         assert isinstance(note["uid"], int)
 
         assert eval_id == note["evaluation_id"]
+
+
+def test_evaluation_create(api_admin_headers):
+    """
+    Test /moduleimpl/<int:moduleimpl_id>/evaluation/create
+    """
+    moduleimpl_id = 20
+    e = POST_JSON(
+        f"/moduleimpl/{moduleimpl_id}/evaluation/create",
+        {"description": "eval test"},
+        api_admin_headers,
+    )
+    assert isinstance(e, dict)
+    assert verify_fields(e, EVALUATIONS_FIELDS)
+    # Check default values
+    assert e["note_max"] == 20.0
+    assert e["evaluation_type"] == 0
+    assert e["jour"] is None
+    assert e["date_debut"] is None
+    assert e["date_fin"] is None
+    assert e["visibulletin"] is True
+    assert e["publish_incomplete"] is False
+    assert e["coefficient"] == 1.0
+
+
+# TODO
+# - tester creation UE externe
+# - tester création base test et test API
diff --git a/tests/unit/sco_fake_gen.py b/tests/unit/sco_fake_gen.py
index 93508c7b3..37ca78cc9 100644
--- a/tests/unit/sco_fake_gen.py
+++ b/tests/unit/sco_fake_gen.py
@@ -16,7 +16,14 @@ import typing
 
 from app import db, log
 from app.auth.models import User
-from app.models import Departement, Formation, FormationModalite, Matiere
+from app.models import (
+    Departement,
+    Evaluation,
+    Formation,
+    FormationModalite,
+    Matiere,
+    ModuleImpl,
+)
 from app.scodoc import notesdb as ndb
 from app.scodoc import codes_cursus
 from app.scodoc import sco_edit_matiere
@@ -307,14 +314,15 @@ class ScoFake(object):
         publish_incomplete=None,
         evaluation_type=None,
         numero=None,
-    ):
+    ) -> int:
         args = locals()
         del args["self"]
-        oid = sco_evaluation_db.do_evaluation_create(**args)
-        oids = sco_evaluation_db.do_evaluation_list(args={"evaluation_id": oid})
-        if not oids:
-            raise ScoValueError("evaluation not created !")
-        return oids[0]
+        moduleimpl: ModuleImpl = db.session.get(ModuleImpl, moduleimpl_id)
+        assert moduleimpl
+        evaluation: Evaluation = Evaluation.create(moduleimpl=moduleimpl, **args)
+        db.session.add(evaluation)
+        db.session.commit()
+        return evaluation.id
 
     @logging_meth
     def create_note(
diff --git a/tools/fakedatabase/create_test_api_database.py b/tools/fakedatabase/create_test_api_database.py
index 3407afa24..55fbd4d4b 100644
--- a/tools/fakedatabase/create_test_api_database.py
+++ b/tools/fakedatabase/create_test_api_database.py
@@ -11,7 +11,6 @@ import datetime
 import os
 import random
 import shutil
-import time
 import sys
 
 from app import db
@@ -23,6 +22,7 @@ from app.models import (
     Absence,
     Assiduite,
     Departement,
+    Evaluation,
     Formation,
     FormSemestre,
     FormSemestreEtape,
@@ -235,14 +235,13 @@ def inscrit_etudiants(etuds: list, formsemestre: FormSemestre):
 
 
 def create_evaluations(formsemestre: FormSemestre):
-    "creation d'une evaluation dans cahque modimpl du semestre"
-    for modimpl in formsemestre.modimpls:
+    "Création d'une evaluation dans chaque modimpl du semestre"
+    for moduleimpl in formsemestre.modimpls:
         args = {
-            "moduleimpl_id": modimpl.id,
-            "jour": datetime.date(2022, 3, 1) + datetime.timedelta(days=modimpl.id),
+            "jour": datetime.date(2022, 3, 1) + datetime.timedelta(days=moduleimpl.id),
             "heure_debut": "8h00",
             "heure_fin": "9h00",
-            "description": f"Evaluation-{modimpl.module.code}",
+            "description": f"Evaluation-{moduleimpl.module.code}",
             "note_max": 20,
             "coefficient": 1.0,
             "visibulletin": True,
@@ -250,7 +249,9 @@ def create_evaluations(formsemestre: FormSemestre):
             "evaluation_type": None,
             "numero": None,
         }
-        evaluation_id = sco_evaluation_db.do_evaluation_create(**args)
+        evaluation = Evaluation.create(moduleimpl=moduleimpl, **args)
+        db.session.add(evaluation)
+    db.session.commit()
 
 
 def saisie_notes_evaluations(formsemestre: FormSemestre, user: User):
diff --git a/tools/test_api.sh b/tools/test_api.sh
index f8591db64..637d98d96 100755
--- a/tools/test_api.sh
+++ b/tools/test_api.sh
@@ -39,7 +39,14 @@ echo
 echo Server PID "$pid" running on port "$PORT"
 # ------------------
 
-pytest tests/api
+if [ "$#" -eq 1 ]
+then
+  echo "Starting pytest tests/api"
+  pytest tests/api
+else
+  echo "Starting pytest $@"
+  pytest "$@"
+fi
 
 # ------------------
 echo "Killing server"
-- 
GitLab