From 721a15d5ec59599a0a1fc6f540cdecc97f04aa3c Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Tue, 12 Apr 2022 17:12:51 +0200
Subject: [PATCH] =?UTF-8?q?R=C3=A9-=C3=A9criture=20des=20news.=20Close=20#?=
 =?UTF-8?q?117?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/models/etudiants.py             |   4 +-
 app/models/events.py                | 220 +++++++++++++++++++++++++++-
 app/models/formsemestre.py          |  10 ++
 app/scodoc/sco_dept.py              |   5 +-
 app/scodoc/sco_edit_formation.py    |  18 +--
 app/scodoc/sco_edit_matiere.py      |  18 +--
 app/scodoc/sco_edit_module.py       |  16 +-
 app/scodoc/sco_edit_ue.py           |  17 +--
 app/scodoc/sco_etud.py              |   7 +-
 app/scodoc/sco_evaluation_db.py     |  16 +-
 app/scodoc/sco_evaluations.py       |  26 ++--
 app/scodoc/sco_exceptions.py        |  11 ++
 app/scodoc/sco_formations.py        |  19 ++-
 app/scodoc/sco_formsemestre.py      |   6 +-
 app/scodoc/sco_formsemestre_edit.py |   8 +-
 app/scodoc/sco_import_etuds.py      |  11 +-
 app/scodoc/sco_preferences.py       |   2 +-
 app/scodoc/sco_saisie_notes.py      |  31 ++--
 app/scodoc/sco_synchro_etuds.py     |  13 +-
 app/static/css/scodoc.css           |  10 ++
 app/templates/dept_news.html        |  47 ++++++
 app/views/scolar.py                 |  64 ++++++++
 scodoc.py                           |   1 +
 23 files changed, 461 insertions(+), 119 deletions(-)
 create mode 100644 app/templates/dept_news.html

diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index 962e7baaf..3aacb66a9 100644
--- a/app/models/etudiants.py
+++ b/app/models/etudiants.py
@@ -16,7 +16,7 @@ from app import models
 
 from app.scodoc import notesdb as ndb
 from app.scodoc.sco_bac import Baccalaureat
-from app.scodoc.sco_exceptions import ScoValueError
+from app.scodoc.sco_exceptions import ScoInvalidParamError
 import app.scodoc.sco_utils as scu
 
 
@@ -358,7 +358,7 @@ def make_etud_args(
         try:
             args = {"etudid": int(etudid)}
         except ValueError as exc:
-            raise ScoValueError("Adresse invalide") from exc
+            raise ScoInvalidParamError() from exc
     elif code_nip:
         args = {"code_nip": code_nip}
     elif use_request:  # use form from current request (Flask global)
diff --git a/app/models/events.py b/app/models/events.py
index 55b34d38d..ccb6396e5 100644
--- a/app/models/events.py
+++ b/app/models/events.py
@@ -2,9 +2,21 @@
 
 """Evenements et logs divers
 """
+import datetime
+import re
+
+from flask import g, url_for
+from flask_login import current_user
 
 from app import db
+from app import email
+from app import log
+from app.auth.models import User
 from app.models import SHORT_STR_LEN
+from app.models.formsemestre import FormSemestre
+from app.models.moduleimpls import ModuleImpl
+import app.scodoc.sco_utils as scu
+from app.scodoc import sco_preferences
 
 
 class Scolog(db.Model):
@@ -24,13 +36,213 @@ class Scolog(db.Model):
 class ScolarNews(db.Model):
     """Nouvelles pour page d'accueil"""
 
+    NEWS_INSCR = "INSCR"  # inscription d'étudiants (object=None ou formsemestre_id)
+    NEWS_NOTE = "NOTES"  # saisie note (object=moduleimpl_id)
+    NEWS_FORM = "FORM"  # modification formation (object=formation_id)
+    NEWS_SEM = "SEM"  # creation semestre (object=None)
+    NEWS_ABS = "ABS"  # saisie absence
+    NEWS_MISC = "MISC"  # unused
+    NEWS_MAP = {
+        NEWS_INSCR: "inscription d'étudiants",
+        NEWS_NOTE: "saisie note",
+        NEWS_FORM: "modification formation",
+        NEWS_SEM: "création semestre",
+        NEWS_MISC: "opération",  # unused
+    }
+    NEWS_TYPES = list(NEWS_MAP.keys())
+
     __tablename__ = "scolar_news"
     id = db.Column(db.Integer, primary_key=True)
     dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
-    date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
-    authenticated_user = db.Column(db.Text)  # login, sans contrainte
+    date = db.Column(
+        db.DateTime(timezone=True), server_default=db.func.now(), index=True
+    )
+    authenticated_user = db.Column(db.Text, index=True)  # login, sans contrainte
     # type in 'INSCR', 'NOTES', 'FORM', 'SEM', 'MISC'
-    type = db.Column(db.String(SHORT_STR_LEN))
-    object = db.Column(db.Integer)  # moduleimpl_id, formation_id, formsemestre_id
+    type = db.Column(db.String(SHORT_STR_LEN), index=True)
+    object = db.Column(
+        db.Integer, index=True
+    )  # moduleimpl_id, formation_id, formsemestre_id
     text = db.Column(db.Text)
     url = db.Column(db.Text)
+
+    def __repr__(self):
+        return (
+            f"<{self.__class__.__name__}(id={self.id}, date='{self.date.isoformat()}')>"
+        )
+
+    def __str__(self):
+        "'Chargement notes dans Stage (S3 FI) par Aurélie Dupont'"
+        formsemestre = self.get_news_formsemestre()
+        user = User.query.filter_by(user_name=self.authenticated_user).first()
+
+        sem_text = (
+            f"""(<a href="{url_for('notes.formsemestre_status', scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
+            }">{formsemestre.sem_modalite()}</a>)"""
+            if formsemestre
+            else ""
+        )
+        author = f"par {user.get_nomcomplet()}" if user else ""
+        return f"{self.text} {sem_text} {author}"
+
+    def formatted_date(self) -> str:
+        "06 Avr 14h23"
+        mois = scu.MONTH_NAMES_ABBREV[self.date.month - 1]
+        return f"{self.date.day} {mois} {self.date.hour:02d}h{self.date.minute:02d}"
+
+    def to_dict(self):
+        return {
+            "date": {
+                "display": self.date.strftime("%d/%m/%Y %H:%M"),
+                "timestamp": self.date.timestamp(),
+            },
+            "type": self.NEWS_MAP.get(self.type, "?"),
+            "authenticated_user": self.authenticated_user,
+            "text": self.text,
+        }
+
+    @classmethod
+    def last_news(cls, n=1) -> list:
+        "The most recent n news. Returns list of ScolarNews instances."
+        return cls.query.order_by(cls.date.desc()).limit(n).all()
+
+    @classmethod
+    def add(cls, typ, obj=None, text="", url=None, max_frequency=0):
+        """Enregistre une nouvelle
+        Si max_frequency, ne génère pas 2 nouvelles "identiques"
+         à moins de max_frequency secondes d'intervalle.
+        Deux nouvelles sont considérées comme "identiques" si elles ont
+        même (obj, typ, user).
+        La nouvelle enregistrée est aussi envoyée par mail.
+        """
+        if max_frequency:
+            last_news = (
+                cls.query.filter_by(
+                    dept_id=g.scodoc_dept_id,
+                    authenticated_user=current_user.user_name,
+                    type=typ,
+                    object=obj,
+                )
+                .order_by(cls.date.desc())
+                .limit(1)
+                .first()
+            )
+            if last_news:
+                now = datetime.datetime.now(tz=last_news.date.tzinfo)
+                if (now - last_news.date) < datetime.timedelta(seconds=max_frequency):
+                    # on n'enregistre pas
+                    return
+
+        news = ScolarNews(
+            dept_id=g.scodoc_dept_id,
+            authenticated_user=current_user.user_name,
+            type=typ,
+            object=obj,
+            text=text,
+            url=url,
+        )
+        db.session.add(news)
+        db.session.commit()
+        log(f"news: {news}")
+        news.notify_by_mail()
+
+    def get_news_formsemestre(self) -> FormSemestre:
+        """formsemestre concerné par la nouvelle
+        None si inexistant
+        """
+        formsemestre_id = None
+        if self.type == self.NEWS_INSCR:
+            formsemestre_id = self.object
+        elif self.type == self.NEWS_NOTE:
+            moduleimpl_id = self.object
+            if moduleimpl_id:
+                modimpl = ModuleImpl.query.get(moduleimpl_id)
+                if modimpl is None:
+                    return None  # module does not exists anymore
+                formsemestre_id = modimpl.formsemestre_id
+
+        if not formsemestre_id:
+            return None
+        formsemestre = FormSemestre.query.get(formsemestre_id)
+        return formsemestre
+
+    def notify_by_mail(self):
+        """Notify by email"""
+        formsemestre = self.get_news_formsemestre()
+
+        prefs = sco_preferences.SemPreferences(
+            formsemestre_id=formsemestre.id if formsemestre else None
+        )
+        destinations = prefs["emails_notifications"] or ""
+        destinations = [x.strip() for x in destinations.split(",")]
+        destinations = [x for x in destinations if x]
+        if not destinations:
+            return
+        #
+        txt = self.text
+        if formsemestre:
+            txt += f"""\n\nSemestre {formsemestre.titre_mois()}\n\n"""
+            txt += f"""<a href="{url_for("notes.formsemestre_status", _external=True,
+                    scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
+                }">{formsemestre.sem_modalite()}</a>
+                """
+            user = User.query.filter_by(user_name=self.authenticated_user).first()
+            if user:
+                txt += f"\n\nEffectué par: {user.get_nomcomplet()}\n"
+
+        txt = (
+            "\n"
+            + txt
+            + """\n
+    --- Ceci est un message de notification automatique issu de ScoDoc
+    --- vous recevez ce message car votre adresse est indiquée dans les paramètres de ScoDoc.
+    """
+        )
+
+        # Transforme les URL en URL absolues
+        base = scu.ScoURL()
+        txt = re.sub('href=/.*?"', 'href="' + base + "/", txt)
+
+        # Transforme les liens HTML en texte brut: '<a href="url">texte</a>' devient 'texte: url'
+        # (si on veut des messages non html)
+        txt = re.sub(r'<a.*?href\s*=\s*"(.*?)".*?>(.*?)</a>', r"\2: \1", txt)
+
+        subject = "[ScoDoc] " + self.NEWS_MAP.get(self.type, "?")
+        sender = prefs["email_from_addr"]
+
+        email.send_email(subject, sender, destinations, txt)
+
+    @classmethod
+    def scolar_news_summary_html(cls, n=5) -> str:
+        """News summary, formated in HTML"""
+        news_list = cls.last_news(n=n)
+        if not news_list:
+            return ""
+        H = [
+            f"""<div class="news"><span class="newstitle"><a href="{
+                url_for("scolar.dept_news", scodoc_dept=g.scodoc_dept)
+            }">Dernières opérations</a>
+            </span><ul class="newslist">"""
+        ]
+
+        for news in news_list:
+            H.append(
+                f"""<li class="newslist"><span class="newsdate">{news.formatted_date()}</span><span
+                class="newstext">{news}</span></li>"""
+            )
+
+        H.append("</ul>")
+
+        # Informations générales
+        H.append(
+            f"""<div>
+        Pour être informé des évolutions de ScoDoc,
+        vous pouvez vous
+        <a class="stdlink" href="{scu.SCO_ANNONCES_WEBSITE}">
+        abonner à la liste de diffusion</a>.
+        </div>
+        """
+        )
+
+        H.append("</div>")
+        return "\n".join(H)
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index 4ec90052b..edf5fa68d 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -374,6 +374,16 @@ class FormSemestre(db.Model):
             return self.titre
         return f"{self.titre} {self.formation.get_parcours().SESSION_NAME} {self.semestre_id}"
 
+    def sem_modalite(self) -> str:
+        """Le semestre et la modialité, ex "S2 FI" ou "S3 APP" """
+        if self.semestre_id > 0:
+            descr_sem = f"S{self.semestre_id}"
+        else:
+            descr_sem = ""
+        if self.modalite:
+            descr_sem += " " + self.modalite
+        return descr_sem
+
     def get_abs_count(self, etudid):
         """Les comptes d'absences de cet étudiant dans ce semestre:
         tuple (nb abs, nb abs justifiées)
diff --git a/app/scodoc/sco_dept.py b/app/scodoc/sco_dept.py
index 453aa2f6c..c19f93608 100644
--- a/app/scodoc/sco_dept.py
+++ b/app/scodoc/sco_dept.py
@@ -32,6 +32,7 @@ from flask import g, request
 from flask_login import current_user
 
 import app
+from app.models import ScolarNews
 import app.scodoc.sco_utils as scu
 from app.scodoc.gen_tables import GenTable
 from app.scodoc.sco_permissions import Permission
@@ -40,9 +41,7 @@ import app.scodoc.notesdb as ndb
 from app.scodoc import sco_formsemestre
 from app.scodoc import sco_formsemestre_inscriptions
 from app.scodoc import sco_modalites
-from app.scodoc import sco_news
 from app.scodoc import sco_preferences
-from app.scodoc import sco_up_to_date
 from app.scodoc import sco_users
 
 
@@ -53,7 +52,7 @@ def index_html(showcodes=0, showsemtable=0):
     H = []
 
     # News:
-    H.append(sco_news.scolar_news_summary_html())
+    H.append(ScolarNews.scolar_news_summary_html())
 
     # Avertissement de mise à jour:
     H.append("""<div id="update_warning"></div>""")
diff --git a/app/scodoc/sco_edit_formation.py b/app/scodoc/sco_edit_formation.py
index 48caf2d5a..aabfaddce 100644
--- a/app/scodoc/sco_edit_formation.py
+++ b/app/scodoc/sco_edit_formation.py
@@ -37,6 +37,7 @@ from app.models import SHORT_STR_LEN
 from app.models.formations import Formation
 from app.models.modules import Module
 from app.models.ues import UniteEns
+from app.models import ScolarNews
 
 import app.scodoc.notesdb as ndb
 import app.scodoc.sco_utils as scu
@@ -44,13 +45,10 @@ from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message
 from app.scodoc.sco_exceptions import ScoValueError, ScoLockedFormError
 
 from app.scodoc import html_sco_header
-from app.scodoc import sco_cache
 from app.scodoc import sco_codes_parcours
-from app.scodoc import sco_edit_module
 from app.scodoc import sco_edit_ue
 from app.scodoc import sco_formations
 from app.scodoc import sco_formsemestre
-from app.scodoc import sco_news
 
 
 def formation_delete(formation_id=None, dialog_confirmed=False):
@@ -117,11 +115,10 @@ def do_formation_delete(oid):
     sco_formations._formationEditor.delete(cnx, oid)
 
     # news
-    sco_news.add(
-        typ=sco_news.NEWS_FORM,
-        object=oid,
-        text="Suppression de la formation %(acronyme)s" % F,
-        max_frequency=3,
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_FORM,
+        obj=oid,
+        text=f"Suppression de la formation {F['acronyme']}",
     )
 
 
@@ -281,10 +278,9 @@ def do_formation_create(args):
     #
     r = sco_formations._formationEditor.create(cnx, args)
 
-    sco_news.add(
-        typ=sco_news.NEWS_FORM,
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_FORM,
         text="Création de la formation %(titre)s (%(acronyme)s)" % args,
-        max_frequency=3,
     )
     return r
 
diff --git a/app/scodoc/sco_edit_matiere.py b/app/scodoc/sco_edit_matiere.py
index f691e350a..2ae1c15ab 100644
--- a/app/scodoc/sco_edit_matiere.py
+++ b/app/scodoc/sco_edit_matiere.py
@@ -30,6 +30,7 @@
 """
 import flask
 from flask import g, url_for, request
+from app.models.events import ScolarNews
 from app.models.formations import Matiere
 
 import app.scodoc.notesdb as ndb
@@ -78,8 +79,7 @@ def do_matiere_edit(*args, **kw):
 def do_matiere_create(args):
     "create a matiere"
     from app.scodoc import sco_edit_ue
-    from app.scodoc import sco_formations
-    from app.scodoc import sco_news
+    from app.models import ScolarNews
 
     cnx = ndb.GetDBConnexion()
     # check
@@ -89,9 +89,9 @@ def do_matiere_create(args):
 
     # news
     formation = Formation.query.get(ue["formation_id"])
-    sco_news.add(
-        typ=sco_news.NEWS_FORM,
-        object=ue["formation_id"],
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_FORM,
+        obj=ue["formation_id"],
         text=f"Modification de la formation {formation.acronyme}",
         max_frequency=3,
     )
@@ -174,10 +174,8 @@ def can_delete_matiere(matiere: Matiere) -> tuple[bool, str]:
 
 def do_matiere_delete(oid):
     "delete matiere and attached modules"
-    from app.scodoc import sco_formations
     from app.scodoc import sco_edit_ue
     from app.scodoc import sco_edit_module
-    from app.scodoc import sco_news
 
     cnx = ndb.GetDBConnexion()
     # check
@@ -197,9 +195,9 @@ def do_matiere_delete(oid):
 
     # news
     formation = Formation.query.get(ue["formation_id"])
-    sco_news.add(
-        typ=sco_news.NEWS_FORM,
-        object=ue["formation_id"],
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_FORM,
+        obj=ue["formation_id"],
         text=f"Modification de la formation {formation.acronyme}",
         max_frequency=3,
     )
diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py
index 2f6b3f430..6d1547491 100644
--- a/app/scodoc/sco_edit_module.py
+++ b/app/scodoc/sco_edit_module.py
@@ -38,6 +38,7 @@ from app import models
 from app.models import APO_CODE_STR_LEN
 from app.models import Formation, Matiere, Module, UniteEns
 from app.models import FormSemestre, ModuleImpl
+from app.models import ScolarNews
 
 import app.scodoc.notesdb as ndb
 import app.scodoc.sco_utils as scu
@@ -53,7 +54,6 @@ from app.scodoc import html_sco_header
 from app.scodoc import sco_codes_parcours
 from app.scodoc import sco_edit_matiere
 from app.scodoc import sco_moduleimpl
-from app.scodoc import sco_news
 
 _moduleEditor = ndb.EditableTable(
     "notes_modules",
@@ -98,16 +98,14 @@ def module_list(*args, **kw):
 def do_module_create(args) -> int:
     "Create a module. Returns id of new object."
     # create
-    from app.scodoc import sco_formations
-
     cnx = ndb.GetDBConnexion()
     r = _moduleEditor.create(cnx, args)
 
     # news
     formation = Formation.query.get(args["formation_id"])
-    sco_news.add(
-        typ=sco_news.NEWS_FORM,
-        object=formation.id,
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_FORM,
+        obj=formation.id,
         text=f"Modification de la formation {formation.acronyme}",
         max_frequency=3,
     )
@@ -396,9 +394,9 @@ def do_module_delete(oid):
 
     # news
     formation = module.formation
-    sco_news.add(
-        typ=sco_news.NEWS_FORM,
-        object=mod["formation_id"],
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_FORM,
+        obj=mod["formation_id"],
         text=f"Modification de la formation {formation.acronyme}",
         max_frequency=3,
     )
diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py
index ffe4d64fc..ec4245765 100644
--- a/app/scodoc/sco_edit_ue.py
+++ b/app/scodoc/sco_edit_ue.py
@@ -37,6 +37,7 @@ from app import db
 from app import log
 from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN
 from app.models import Formation, UniteEns, ModuleImpl, Module
+from app.models import ScolarNews
 from app.models.formations import Matiere
 import app.scodoc.notesdb as ndb
 import app.scodoc.sco_utils as scu
@@ -55,15 +56,11 @@ from app.scodoc import html_sco_header
 from app.scodoc import sco_cache
 from app.scodoc import sco_codes_parcours
 from app.scodoc import sco_edit_apc
-from app.scodoc import sco_edit_formation
 from app.scodoc import sco_edit_matiere
 from app.scodoc import sco_edit_module
-from app.scodoc import sco_etud
 from app.scodoc import sco_formsemestre
 from app.scodoc import sco_groups
 from app.scodoc import sco_moduleimpl
-from app.scodoc import sco_news
-from app.scodoc import sco_permissions
 from app.scodoc import sco_preferences
 from app.scodoc import sco_tag_module
 
@@ -138,9 +135,9 @@ def do_ue_create(args):
     ue = UniteEns.query.get(ue_id)
     flash(f"UE créée (code {ue.ue_code})")
     formation = Formation.query.get(args["formation_id"])
-    sco_news.add(
-        typ=sco_news.NEWS_FORM,
-        object=args["formation_id"],
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_FORM,
+        obj=args["formation_id"],
         text=f"Modification de la formation {formation.acronyme}",
         max_frequency=3,
     )
@@ -222,9 +219,9 @@ def do_ue_delete(ue_id, delete_validations=False, force=False):
     sco_cache.invalidate_formsemestre()
     # news
     F = sco_formations.formation_list(args={"formation_id": ue.formation_id})[0]
-    sco_news.add(
-        typ=sco_news.NEWS_FORM,
-        object=ue.formation_id,
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_FORM,
+        obj=ue.formation_id,
         text="Modification de la formation %(acronyme)s" % F,
         max_frequency=3,
     )
diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py
index 710f85bc4..598ec96b0 100644
--- a/app/scodoc/sco_etud.py
+++ b/app/scodoc/sco_etud.py
@@ -637,7 +637,7 @@ def create_etud(cnx, args={}):
     Returns:
         etud, l'étudiant créé.
     """
-    from app.scodoc import sco_news
+    from app.models import ScolarNews
 
     # creation d'un etudiant
     etudid = etudident_create(cnx, args)
@@ -671,9 +671,8 @@ def create_etud(cnx, args={}):
     etud = etudident_list(cnx, {"etudid": etudid})[0]
     fill_etuds_info([etud])
     etud["url"] = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
-    sco_news.add(
-        typ=sco_news.NEWS_INSCR,
-        object=None,  # pas d'object pour ne montrer qu'un etudiant
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_INSCR,
         text='Nouvel étudiant <a href="%(url)s">%(nomprenom)s</a>' % etud,
         url=etud["url"],
     )
diff --git a/app/scodoc/sco_evaluation_db.py b/app/scodoc/sco_evaluation_db.py
index 59dbc5fe8..7aec90889 100644
--- a/app/scodoc/sco_evaluation_db.py
+++ b/app/scodoc/sco_evaluation_db.py
@@ -28,7 +28,6 @@
 """Gestion evaluations (ScoDoc7, sans SQlAlchemy)
 """
 
-import datetime
 import pprint
 
 import flask
@@ -37,6 +36,7 @@ from flask_login import current_user
 
 from app import log
 
+from app.models import ScolarNews
 from app.models.evaluations import evaluation_enrich_dict, check_evaluation_args
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
@@ -44,9 +44,7 @@ from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
 
 from app.scodoc import sco_cache
 from app.scodoc import sco_edit_module
-from app.scodoc import sco_formsemestre
 from app.scodoc import sco_moduleimpl
-from app.scodoc import sco_news
 from app.scodoc import sco_permissions_check
 
 
@@ -179,9 +177,9 @@ def do_evaluation_create(
     mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
     mod["moduleimpl_id"] = M["moduleimpl_id"]
     mod["url"] = "Notes/moduleimpl_status?moduleimpl_id=%(moduleimpl_id)s" % mod
-    sco_news.add(
-        typ=sco_news.NEWS_NOTE,
-        object=moduleimpl_id,
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_NOTE,
+        obj=moduleimpl_id,
         text='Création d\'une évaluation dans <a href="%(url)s">%(titre)s</a>' % mod,
         url=mod["url"],
     )
@@ -240,9 +238,9 @@ def do_evaluation_delete(evaluation_id):
     mod["url"] = (
         scu.NotesURL() + "/moduleimpl_status?moduleimpl_id=%(moduleimpl_id)s" % mod
     )
-    sco_news.add(
-        typ=sco_news.NEWS_NOTE,
-        object=moduleimpl_id,
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_NOTE,
+        obj=moduleimpl_id,
         text='Suppression d\'une évaluation dans <a href="%(url)s">%(titre)s</a>' % mod,
         url=mod["url"],
     )
diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py
index 43b585907..6152e8810 100644
--- a/app/scodoc/sco_evaluations.py
+++ b/app/scodoc/sco_evaluations.py
@@ -36,32 +36,27 @@ from flask import g
 from flask_login import current_user
 from flask import request
 
-from app import log
-
 from app.comp import res_sem
 from app.comp.res_compat import NotesTableCompat
 from app.models import FormSemestre
+from app.models import ScolarNews
 
 import app.scodoc.sco_utils as scu
 from app.scodoc.sco_utils import ModuleType
 import app.scodoc.notesdb as ndb
-from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
-import sco_version
 from app.scodoc.gen_tables import GenTable
 from app.scodoc import html_sco_header
 from app.scodoc import sco_evaluation_db
 from app.scodoc import sco_abs
-from app.scodoc import sco_cache
 from app.scodoc import sco_edit_module
 from app.scodoc import sco_edit_ue
-from app.scodoc import sco_formsemestre
 from app.scodoc import sco_formsemestre_inscriptions
 from app.scodoc import sco_groups
 from app.scodoc import sco_moduleimpl
-from app.scodoc import sco_news
 from app.scodoc import sco_permissions_check
 from app.scodoc import sco_preferences
 from app.scodoc import sco_users
+import sco_version
 
 
 # --------------------------------------------------------------------
@@ -633,13 +628,16 @@ def evaluation_describe(evaluation_id="", edit_in_place=True):
         '<span class="evallink"><a class="stdlink" href="evaluation_listenotes?moduleimpl_id=%s">voir toutes les notes du module</a></span>'
         % moduleimpl_id
     )
-    mod_descr = '<a href="moduleimpl_status?moduleimpl_id=%s">%s %s</a> <span class="resp">(resp. <a title="%s">%s</a>)</span> %s' % (
-        moduleimpl_id,
-        Mod["code"] or "",
-        Mod["titre"] or "?",
-        nomcomplet,
-        resp,
-        link,
+    mod_descr = (
+        '<a href="moduleimpl_status?moduleimpl_id=%s">%s %s</a> <span class="resp">(resp. <a title="%s">%s</a>)</span> %s'
+        % (
+            moduleimpl_id,
+            Mod["code"] or "",
+            Mod["titre"] or "?",
+            nomcomplet,
+            resp,
+            link,
+        )
     )
 
     etit = E["description"] or ""
diff --git a/app/scodoc/sco_exceptions.py b/app/scodoc/sco_exceptions.py
index 35d2d9d6e..17d39a4c2 100644
--- a/app/scodoc/sco_exceptions.py
+++ b/app/scodoc/sco_exceptions.py
@@ -63,6 +63,17 @@ class ScoFormatError(ScoValueError):
     pass
 
 
+class ScoInvalidParamError(ScoValueError):
+    """Paramètres requete invalides.
+    A utilisée lorsqu'une route est appelée avec des paramètres invalides
+    (id strings, ...)
+    """
+
+    def __init__(self, msg=None, dest_url=None):
+        msg = msg or "Adresse invalide. Vérifiez vos signets."
+        super().__init__(msg, dest_url=dest_url)
+
+
 class ScoPDFFormatError(ScoValueError):
     "erreur génération PDF (templates platypus, ...)"
 
diff --git a/app/scodoc/sco_formations.py b/app/scodoc/sco_formations.py
index 8b99a0678..178138b22 100644
--- a/app/scodoc/sco_formations.py
+++ b/app/scodoc/sco_formations.py
@@ -39,12 +39,12 @@ import app.scodoc.notesdb as ndb
 from app import db
 from app import log
 from app.models import Formation, Module
+from app.models import ScolarNews
 from app.scodoc import sco_codes_parcours
 from app.scodoc import sco_edit_matiere
 from app.scodoc import sco_edit_module
 from app.scodoc import sco_edit_ue
 from app.scodoc import sco_formsemestre
-from app.scodoc import sco_news
 from app.scodoc import sco_preferences
 from app.scodoc import sco_tag_module
 from app.scodoc import sco_xml
@@ -351,10 +351,13 @@ def formation_list_table(formation_id=None, args={}):
         else:
             but_locked = '<span class="but_placeholder"></span>'
         if editable and not locked:
-            but_suppr = '<a class="stdlink" href="formation_delete?formation_id=%s" id="delete-formation-%s">%s</a>' % (
-                f["formation_id"],
-                f["acronyme"].lower().replace(" ", "-"),
-                suppricon,
+            but_suppr = (
+                '<a class="stdlink" href="formation_delete?formation_id=%s" id="delete-formation-%s">%s</a>'
+                % (
+                    f["formation_id"],
+                    f["acronyme"].lower().replace(" ", "-"),
+                    suppricon,
+                )
             )
         else:
             but_suppr = '<span class="but_placeholder"></span>'
@@ -422,9 +425,9 @@ def formation_create_new_version(formation_id, redirect=True):
     new_id, modules_old2new, ues_old2new = formation_import_xml(xml_data)
     # news
     F = formation_list(args={"formation_id": new_id})[0]
-    sco_news.add(
-        typ=sco_news.NEWS_FORM,
-        object=new_id,
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_FORM,
+        obj=new_id,
         text="Nouvelle version de la formation %(acronyme)s" % F,
     )
     if redirect:
diff --git a/app/scodoc/sco_formsemestre.py b/app/scodoc/sco_formsemestre.py
index e5f8129e8..5d8510ac2 100644
--- a/app/scodoc/sco_formsemestre.py
+++ b/app/scodoc/sco_formsemestre.py
@@ -229,7 +229,7 @@ def etapes_apo_str(etapes):
 def do_formsemestre_create(args, silent=False):
     "create a formsemestre"
     from app.scodoc import sco_groups
-    from app.scodoc import sco_news
+    from app.models import ScolarNews
 
     cnx = ndb.GetDBConnexion()
     formsemestre_id = _formsemestreEditor.create(cnx, args)
@@ -254,8 +254,8 @@ def do_formsemestre_create(args, silent=False):
     args["formsemestre_id"] = formsemestre_id
     args["url"] = "Notes/formsemestre_status?formsemestre_id=%(formsemestre_id)s" % args
     if not silent:
-        sco_news.add(
-            typ=sco_news.NEWS_SEM,
+        ScolarNews.add(
+            typ=ScolarNews.NEWS_SEM,
             text='Création du semestre <a href="%(url)s">%(titre)s</a>' % args,
             url=args["url"],
         )
diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py
index 7d903f6cf..3c88ff706 100644
--- a/app/scodoc/sco_formsemestre_edit.py
+++ b/app/scodoc/sco_formsemestre_edit.py
@@ -1493,11 +1493,9 @@ def do_formsemestre_delete(formsemestre_id):
     sco_formsemestre._formsemestreEditor.delete(cnx, formsemestre_id)
 
     # news
-    from app.scodoc import sco_news
-
-    sco_news.add(
-        typ=sco_news.NEWS_SEM,
-        object=formsemestre_id,
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_SEM,
+        obj=formsemestre_id,
         text="Suppression du semestre %(titre)s" % sem,
     )
 
diff --git a/app/scodoc/sco_import_etuds.py b/app/scodoc/sco_import_etuds.py
index da117b5d0..32b2530d4 100644
--- a/app/scodoc/sco_import_etuds.py
+++ b/app/scodoc/sco_import_etuds.py
@@ -40,6 +40,8 @@ from flask import g, url_for
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
 from app import log
+from app.models import ScolarNews
+
 from app.scodoc.sco_excel import COLORS
 from app.scodoc.sco_formsemestre_inscriptions import (
     do_formsemestre_inscription_with_modules,
@@ -54,14 +56,13 @@ from app.scodoc.sco_exceptions import (
     ScoLockedFormError,
     ScoGenError,
 )
+
 from app.scodoc import html_sco_header
 from app.scodoc import sco_cache
 from app.scodoc import sco_etud
-from app.scodoc import sco_formsemestre
 from app.scodoc import sco_groups
 from app.scodoc import sco_excel
 from app.scodoc import sco_groups_view
-from app.scodoc import sco_news
 from app.scodoc import sco_preferences
 
 # format description (in tools/)
@@ -472,11 +473,11 @@ def scolars_import_excel_file(
 
     diag.append("Import et inscription de %s étudiants" % len(created_etudids))
 
-    sco_news.add(
-        typ=sco_news.NEWS_INSCR,
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_INSCR,
         text="Inscription de %d étudiants"  # peuvent avoir ete inscrits a des semestres differents
         % len(created_etudids),
-        object=formsemestre_id,
+        obj=formsemestre_id,
     )
 
     log("scolars_import_excel_file: completing transaction")
diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py
index 112b0da50..9ccb63628 100644
--- a/app/scodoc/sco_preferences.py
+++ b/app/scodoc/sco_preferences.py
@@ -350,7 +350,7 @@ class BasePreferences(object):
                     "initvalue": "",
                     "title": "e-mails à qui notifier les opérations",
                     "size": 70,
-                    "explanation": "adresses séparées par des virgules; notifie les opérations (saisies de notes, etc). (vous pouvez préférer utiliser le flux rss)",
+                    "explanation": "adresses séparées par des virgules; notifie les opérations (saisies de notes, etc).",
                     "category": "general",
                     "only_global": False,  # peut être spécifique à un semestre
                 },
diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py
index fbef92458..23847c994 100644
--- a/app/scodoc/sco_saisie_notes.py
+++ b/app/scodoc/sco_saisie_notes.py
@@ -39,6 +39,7 @@ from flask_login import current_user
 from app.comp import res_sem
 from app.comp.res_compat import NotesTableCompat
 from app.models import FormSemestre
+from app.models import ScolarNews
 import app.scodoc.sco_utils as scu
 from app.scodoc.sco_utils import ModuleType
 import app.scodoc.notesdb as ndb
@@ -48,6 +49,7 @@ from app.scodoc.sco_exceptions import (
     InvalidNoteValue,
     NoteProcessError,
     ScoGenError,
+    ScoInvalidParamError,
     ScoValueError,
 )
 from app.scodoc.TrivialFormulator import TrivialFormulator, TF
@@ -64,7 +66,6 @@ from app.scodoc import sco_formsemestre_inscriptions
 from app.scodoc import sco_groups
 from app.scodoc import sco_groups_view
 from app.scodoc import sco_moduleimpl
-from app.scodoc import sco_news
 from app.scodoc import sco_permissions_check
 from app.scodoc import sco_undo_notes
 from app.scodoc import sco_etud
@@ -274,9 +275,9 @@ def do_evaluation_upload_xls():
                 moduleimpl_id=mod["moduleimpl_id"],
                 _external=True,
             )
-            sco_news.add(
-                typ=sco_news.NEWS_NOTE,
-                object=M["moduleimpl_id"],
+            ScolarNews.add(
+                typ=ScolarNews.NEWS_NOTE,
+                obj=M["moduleimpl_id"],
                 text='Chargement notes dans <a href="%(url)s">%(titre)s</a>' % mod,
                 url=mod["url"],
             )
@@ -359,9 +360,9 @@ def do_evaluation_set_missing(evaluation_id, value, dialog_confirmed=False):
         scodoc_dept=g.scodoc_dept,
         moduleimpl_id=mod["moduleimpl_id"],
     )
-    sco_news.add(
-        typ=sco_news.NEWS_NOTE,
-        object=M["moduleimpl_id"],
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_NOTE,
+        obj=M["moduleimpl_id"],
         text='Initialisation notes dans <a href="%(url)s">%(titre)s</a>' % mod,
         url=mod["url"],
     )
@@ -451,9 +452,9 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
     mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
     mod["moduleimpl_id"] = M["moduleimpl_id"]
     mod["url"] = "Notes/moduleimpl_status?moduleimpl_id=%(moduleimpl_id)s" % mod
-    sco_news.add(
-        typ=sco_news.NEWS_NOTE,
-        object=M["moduleimpl_id"],
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_NOTE,
+        obj=M["moduleimpl_id"],
         text='Suppression des notes d\'une évaluation dans <a href="%(url)s">%(titre)s</a>'
         % mod,
         url=mod["url"],
@@ -893,10 +894,12 @@ def has_existing_decision(M, E, etudid):
 
 def saisie_notes(evaluation_id, group_ids=[]):
     """Formulaire saisie notes d'une évaluation pour un groupe"""
+    if not isinstance(evaluation_id, int):
+        raise ScoInvalidParamError()
     group_ids = [int(group_id) for group_id in group_ids]
     evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})
     if not evals:
-        raise ScoValueError("invalid evaluation_id")
+        raise ScoValueError("évaluation inexistante")
     E = evals[0]
     M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0]
     formsemestre_id = M["formsemestre_id"]
@@ -1283,9 +1286,9 @@ def save_note(etudid=None, evaluation_id=None, value=None, comment=""):
             nbchanged, _, existing_decisions = notes_add(
                 authuser, evaluation_id, L, comment=comment, do_it=True
             )
-            sco_news.add(
-                typ=sco_news.NEWS_NOTE,
-                object=M["moduleimpl_id"],
+            ScolarNews.add(
+                typ=ScolarNews.NEWS_NOTE,
+                obj=M["moduleimpl_id"],
                 text='Chargement notes dans <a href="%(url)s">%(titre)s</a>' % Mod,
                 url=Mod["url"],
                 max_frequency=30 * 60,  # 30 minutes
diff --git a/app/scodoc/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py
index db775438c..6483ffcff 100644
--- a/app/scodoc/sco_synchro_etuds.py
+++ b/app/scodoc/sco_synchro_etuds.py
@@ -29,12 +29,14 @@
 """
 
 import time
-import pprint
 from operator import itemgetter
 
 from flask import g, url_for
 from flask_login import current_user
 
+from app import log
+from app.models import ScolarNews
+
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
 from app.scodoc import html_sco_header
@@ -43,11 +45,8 @@ from app.scodoc import sco_formsemestre
 from app.scodoc import sco_formsemestre_inscriptions
 from app.scodoc import sco_groups
 from app.scodoc import sco_inscr_passage
-from app.scodoc import sco_news
-from app.scodoc import sco_excel
 from app.scodoc import sco_portal_apogee
 from app.scodoc import sco_etud
-from app import log
 from app.scodoc.sco_exceptions import ScoValueError
 from app.scodoc.sco_permissions import Permission
 
@@ -701,10 +700,10 @@ def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident):
         sco_cache.invalidate_formsemestre()
         raise
 
-    sco_news.add(
-        typ=sco_news.NEWS_INSCR,
+    ScolarNews.add(
+        typ=ScolarNews.NEWS_INSCR,
         text="Import Apogée de %d étudiants en " % len(created_etudids),
-        object=sem["formsemestre_id"],
+        obj=sem["formsemestre_id"],
     )
 
 
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index 9811b3045..a153ecc02 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -487,6 +487,16 @@ div.news {
   border-radius: 8px;
 }
 
+div.news a {
+  color: black;
+  text-decoration: none;
+}
+
+div.news a:hover {
+  color: rgb(153, 51, 51);
+  text-decoration: underline;
+}
+
 span.newstitle {
   font-weight: bold;
 }
diff --git a/app/templates/dept_news.html b/app/templates/dept_news.html
new file mode 100644
index 000000000..23e3f26c8
--- /dev/null
+++ b/app/templates/dept_news.html
@@ -0,0 +1,47 @@
+{# -*- mode: jinja-html -*- #}
+{% extends "sco_page.html" %}
+{% block styles %}
+{{super()}}
+{% endblock %}
+
+{% block app_content %}
+<h2>Opérations dans le département {{g.scodoc_dept}}</h2>
+
+<table id="dept_news" class="table table-striped">
+<thead>
+    <tr>
+    <th>Date</th>
+    <th>Type</th>
+    <th>Auteur</th>
+    <th>Détail</th>
+    </tr>
+</thead>
+<tbody>
+</tbody>
+</table>
+{% endblock %}
+
+
+{% block scripts %}
+{{super()}}
+<script>
+    $(document).ready(function () {
+      $('#dept_news').DataTable({
+        ajax: '{{url_for("scolar.dept_news_json", scodoc_dept=g.scodoc_dept)}}',
+        serverSide: true,
+        columns: [
+          { 
+            data: {
+                _:    "date.display",
+                sort: "date.timestamp"
+            } 
+          },
+          {data: 'type', searchable: false},
+          {data: 'authenticated_user', orderable: false, searchable: true},
+          {data: 'text', orderable: false, searchable: true}
+        ],
+        "order": [[ 0, "desc" ]]
+      });
+    });
+</script>
+{% endblock %}
diff --git a/app/views/scolar.py b/app/views/scolar.py
index 1b88575e4..de987cb17 100644
--- a/app/views/scolar.py
+++ b/app/views/scolar.py
@@ -41,6 +41,7 @@ from flask_wtf import FlaskForm
 from flask_wtf.file import FileField, FileAllowed
 from wtforms import SubmitField
 
+from app import db
 from app import log
 from app.decorators import (
     scodoc,
@@ -52,8 +53,10 @@ from app.decorators import (
 )
 from app.models.etudiants import Identite
 from app.models.etudiants import make_etud_args
+from app.models.events import ScolarNews
 
 from app.views import scolar_bp as bp
+from app.views import ScoData
 
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
@@ -339,6 +342,67 @@ def install_info():
     return sco_up_to_date.is_up_to_date()
 
 
+@bp.route("/dept_news")
+@scodoc
+@permission_required(Permission.ScoView)
+def dept_news():
+    "Affiche table des dernières opérations"
+    return render_template(
+        "dept_news.html", title=f"Opérations {g.scodoc_dept}", sco=ScoData()
+    )
+
+
+@bp.route("/dept_news_json")
+@scodoc
+@permission_required(Permission.ScoView)
+def dept_news_json():
+    "Table des news du département"
+    start = request.args.get("start", type=int)
+    length = request.args.get("length", type=int)
+
+    log(f"dept_news_json( start={start}, length={length})")
+    query = ScolarNews.query.filter_by(dept_id=g.scodoc_dept_id)
+    # search
+    search = request.args.get("search[value]")
+    if search:
+        query = query.filter(
+            db.or_(
+                ScolarNews.authenticated_user.like(f"%{search}%"),
+                ScolarNews.text.like(f"%{search}%"),
+            )
+        )
+    total_filtered = query.count()
+    # sorting
+    order = []
+    i = 0
+    while True:
+        col_index = request.args.get(f"order[{i}][column]")
+        if col_index is None:
+            break
+        col_name = request.args.get(f"columns[{col_index}][data]")
+        if col_name not in ["date", "type", "authenticated_user"]:
+            col_name = "date"
+        descending = request.args.get(f"order[{i}][dir]") == "desc"
+        col = getattr(ScolarNews, col_name)
+        if descending:
+            col = col.desc()
+        order.append(col)
+        i += 1
+    if order:
+        query = query.order_by(*order)
+
+    # pagination
+    query = query.offset(start).limit(length)
+    data = [news.to_dict() for news in query]
+    # response
+    return {
+        "data": data,
+        "recordsFiltered": total_filtered,
+        "recordsTotal": ScolarNews.query.count(),
+        "draw": request.args.get("draw", type=int),
+    }
+
+
 sco_publish(
     "/trombino", sco_trombino.trombino, Permission.ScoView, methods=["GET", "POST"]
 )
diff --git a/scodoc.py b/scodoc.py
index c728bed98..1d3b17262 100755
--- a/scodoc.py
+++ b/scodoc.py
@@ -77,6 +77,7 @@ def make_shell_context():
         "pp": pp,
         "Role": Role,
         "scolar": scolar,
+        "ScolarNews": models.ScolarNews,
         "scu": scu,
         "UniteEns": UniteEns,
         "User": User,
-- 
GitLab