diff --git a/plugins/benachrichtigung/__init__.py b/plugins/benachrichtigung/__init__.py
index 9c6a12d..bacf811 100644
--- a/plugins/benachrichtigung/__init__.py
+++ b/plugins/benachrichtigung/__init__.py
@@ -80,6 +80,19 @@ class BenachrichtigungPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/benachrichtigung")]
+ def nav_badges(self, user_id: int) -> dict[str, int]:
+ """Nav-Badge: Anzahl ungelesener Benachrichtigungen."""
+ with self.context.session_factory() as db:
+ ungelesen = db.scalar(
+ select(func.count())
+ .select_from(Benachrichtigung)
+ .where(
+ Benachrichtigung.user_id == user_id,
+ Benachrichtigung.gelesen.is_(False),
+ )
+ ) or 0
+ return {"/benachrichtigung": ungelesen} if ungelesen else {}
+
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: Anzahl ungelesener In-App-Benachrichtigungen."""
with self.context.session_factory() as db:
diff --git a/src/redaktionskern/app.py b/src/redaktionskern/app.py
index dae878b..7ec33dc 100644
--- a/src/redaktionskern/app.py
+++ b/src/redaktionskern/app.py
@@ -20,6 +20,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
from sqlalchemy import func, select
+from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware
from redaktionskern.auth import routen as auth_routen
@@ -134,6 +135,33 @@ def create_app(settings: Settings | None = None) -> FastAPI:
for plugin in plugins:
app.include_router(plugin.router)
+ class NavBadgeMiddleware(BaseHTTPMiddleware):
+ """Sammelt optionale Zähler-Badges der Plugins für die Navigation.
+
+ Läuft INNERHALB der SessionMiddleware (wurde vor ihr registriert),
+ greift also nur lesend auf die Session zu. Fehler eines Plugins
+ dürfen die Anfrage nie brechen.
+ """
+
+ async def dispatch(self, request: Request, call_next):
+ badges: dict[str, int] = {}
+ user_id = request.session.get("user_id")
+ if user_id is not None:
+ for plugin in registry.all():
+ try:
+ for url, anzahl in (plugin.nav_badges(user_id) or {}).items():
+ if anzahl and anzahl > 0:
+ badges[url] = badges.get(url, 0) + anzahl
+ except Exception: # pragma: no cover - defensiv
+ import logging
+
+ logging.getLogger("redaktionskern").exception(
+ "nav_badges von Plugin %r fehlgeschlagen.", plugin.name
+ )
+ request.state.nav_badges = badges
+ return await call_next(request)
+
+ app.add_middleware(NavBadgeMiddleware)
app.add_middleware(
SessionMiddleware,
secret_key=settings.session_secret,
diff --git a/src/redaktionskern/contracts.py b/src/redaktionskern/contracts.py
index 3ed0b76..67e8a82 100644
--- a/src/redaktionskern/contracts.py
+++ b/src/redaktionskern/contracts.py
@@ -106,6 +106,14 @@ class BasePlugin:
"""Einträge für die Hauptnavigation (leer = keiner)."""
return []
+ def nav_badges(self, user_id: int) -> dict[str, int]:
+ """Zähler-Badges für Navigations-Einträge: {URL: Anzahl}.
+
+ Der Kern ruft diesen Hook pro Anfrage für den angemeldeten
+ Benutzer auf (leer = kein Badge). Werte <= 0 werden ignoriert.
+ """
+ return {}
+
def dashboard_karten(self, user) -> list[DashboardKarte]:
"""Dashboard-Karten des Plugins für den angemeldeten Benutzer.
diff --git a/src/redaktionskern/templates/base.html b/src/redaktionskern/templates/base.html
index e7685e7..86dbbcc 100644
--- a/src/redaktionskern/templates/base.html
+++ b/src/redaktionskern/templates/base.html
@@ -35,8 +35,11 @@
{% for eintrag in plugin_nav %}
{% set aktiv = request.url.path == eintrag.url or request.url.path.startswith(eintrag.url ~ '/') %}
+ {% set badge_n = request.state.nav_badges.get(eintrag.url, 0) %}
{{ eintrag.label }}
+ class="text-sm pb-0.5 border-b-2 inline-flex items-center {% if aktiv %}text-emerald-700 font-semibold border-emerald-600{% else %}text-slate-600 border-transparent hover:text-emerald-700 hover:border-emerald-300{% endif %}">{{ eintrag.label }}
+ {%- if badge_n %}{{ badge_n }}{% endif -%}
+
{% endfor %}
@@ -80,9 +83,11 @@
class="md:hidden border-t border-slate-200 bg-white px-4 pb-3 pt-2">
{% for eintrag in plugin_nav %}
{% set aktiv = request.url.path == eintrag.url or request.url.path.startswith(eintrag.url ~ '/') %}
+ {% set badge_n = request.state.nav_badges.get(eintrag.url, 0) %}
+ class="flex items-center py-3 text-sm border-b border-slate-100 last:border-0 {% if aktiv %}font-semibold text-emerald-700{% else %}font-medium text-slate-700 hover:text-emerald-700{% endif %}">
{{ eintrag.label }}
+ {%- if badge_n %}{{ badge_n }}{% endif -%}
{% endfor %}
{% if not user %}