P2: Ungelesen-Badge in der Hauptnavigation

- 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
This commit is contained in:
Flo Hartmann
2026-08-26 09:08:24 +00:00
parent d6ed8fa56e
commit f90c0de46d
4 changed files with 56 additions and 2 deletions

View File

@@ -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:

View File

@@ -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,

View File

@@ -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.

View File

@@ -35,8 +35,11 @@
<span class="hidden md:flex items-center gap-5 flex-1">
{% 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) %}
<a href="{{ eintrag.url }}" {% if aktiv %}aria-current="page"{% endif %}
class="text-sm pb-0.5 border-b-2 {% 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 }}</a>
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 %}<span class="ml-1.5 inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full bg-red-600 text-white text-[11px] font-semibold" aria-label="{{ badge_n }} ungelesen">{{ badge_n }}</span>{% endif -%}
</a>
{% endfor %}
</span>
<!-- Mobil: Burger-Button (Alpine.js), Touch-Ziel &ge;&ge; 44px -->
@@ -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) %}
<a href="{{ eintrag.url }}" @click="menue = false" {% if aktiv %}aria-current="page"{% endif %}
class="block 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 %}">
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 %}<span class="ml-2 inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full bg-red-600 text-white text-[11px] font-semibold" aria-label="{{ badge_n }} ungelesen">{{ badge_n }}</span>{% endif -%}
</a>
{% endfor %}
{% if not user %}