From f90c0de46dc7407f52cf31c830f25b1355d57864 Mon Sep 17 00:00:00 2001 From: Flo Hartmann Date: Wed, 26 Aug 2026 09:08:24 +0000 Subject: [PATCH] P2: Ungelesen-Badge in der Hauptnavigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Plugin-Vertrag: nav_badges(user_id) -> {URL: Anzahl} - Kern-Middleware sammelt Badges pro Anfrage in request.state.nav_badges - benachrichtigung-Plugin meldet die ungelesene Anzahl - Rotes Zähler-Badge am Nav-Eintrag (Desktop + Mobil), nur wenn > 0 --- plugins/benachrichtigung/__init__.py | 13 ++++++++++++ src/redaktionskern/app.py | 28 ++++++++++++++++++++++++++ src/redaktionskern/contracts.py | 8 ++++++++ src/redaktionskern/templates/base.html | 9 +++++++-- 4 files changed, 56 insertions(+), 2 deletions(-) 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 @@ @@ -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 %}