diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index 962e7baaf8517ca9c4be24650d6d896f02c89ae9..3aacb66a9fe8552d7ddfd6d66e584ed6cb5775bc 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 55b34d38d44ef9b86f79d5cdc68e292ba23b29cc..ccb6396e5028aec9e246c507a5b87bb019948158 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 4ec90052b766ac887ef4b76eedfb305aa1a8c757..edf5fa68d26a059d560bc78e8b88e89cd7fcbfd7 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 453aa2f6cf4e5908c02dc7977c72fe57a416ece2..c19f936084e9d812d2d3dea689acb7d1a4198cf9 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 48caf2d5a9ace851ec4f5df8498d2b68475768ae..aabfaddce24f2d98e14a041f52bb941021d690c6 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 f691e350abf3a4cadc96f34e59937b2023a8b308..2ae1c15ab49d7025b7fbc2163bad045d7d560fa0 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 2f6b3f43019e696e06c7412297050f3d8eecf857..6d15474913cfb20bfee6a07fe8c314fb1f2e83c5 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 ffe4d64fc5392ff3bf29b4ef7c94311a626f321e..ec4245765599043a37117d08298869bd8fec8552 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 710f85bc4d1b7ec81b9491f1f5a63cc371effcce..598ec96b0ca611e71c8f30a68e7130b62ff35fbd 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 59dbc5fe82fb8f07073d5fa8f88edc17f8ea3984..7aec908896c75847dbd4a682c286557cbbc0334d 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 43b585907032f13204b975189d3d1890eb4d0270..6152e881052b2c8ef9506787224dd56b5da48195 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 35d2d9d6ea5e3f74fe3afc6897ce389f62a39c21..17d39a4c26d696f9e84c58ebcad35fd79c2afe02 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 8b99a0678310b6cfd0516e7895591eba277eb763..178138b220ec48e4b7b7e13e602d09f51946efcd 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 e5f8129e858c5ee11700cc3d222fb51e7fb5ec51..5d8510ac229156bcd429a048586d56de2122ed4e 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 7d903f6cfeccddf6edfa44744b5940b38ccb4573..3c88ff70689d32e058de78884a606301a6148779 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 da117b5d0bc605ddd142073b390d4f50feca7e5b..32b2530d470d1048ce602963c4e0bd23790e4b00 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 112b0da50130d8979ed5d03ad2657d1b27d49cf9..9ccb636280cd66923e9a7679690f1b19f9409302 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 fbef924585e350d199243a13bac1f855db6d13ba..23847c9949a4fc2dc264eec9e2a15eb0ab297eb9 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 db775438c98a91b18e56423dbfd7660c4cba1b0c..6483ffcff7b322088b04e0029174496358915752 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 9811b3045ab7ef1c5e4c834f8e810b420cf47bb2..a153ecc02fd0bc2cb0957749300d3cd28b53b208 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 0000000000000000000000000000000000000000..23e3f26c80cbb93ef3c83fc9808458794850a8de
--- /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 1b88575e4199e98344fe03328b1cd595fb9aa7b5..de987cb17bc81d137ec03aa45f79995bd0198f52 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 c728bed98fe3831a99ee66b7b6189f371f86b5fd..1d3b172627156b447bd5d45046e33cdd4b5f45b5 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,