P1: Rollenbasiertes Dashboard mit Plugin-Karten

- Plugin-Vertrag erweitert: optionaler Hook dashboard_karten(user) +
  DashboardKarte-Dataclass (Kern bleibt schlank, Fachlogik im Plugin)
- Karten: Neuheiten+System-Status (neuheiten), Planungs-Stats bzw.
  eigene Einträge (planung), offene Dedup-Konflikte (dedup), nächste
  Redaktionsschlüsse (erinnerung), ungelesene Benachrichtigungen
  (benachrichtigung), letzte Audit-Einträge (audit-log, nur Admin),
  Benutzer-Anzahl (Kern, nur Admin)
- 'Geladene Plugins' als aufklappbarer Admin-Technikbereich
This commit is contained in:
Flo Hartmann
2026-08-26 08:48:30 +00:00
parent 742c265df8
commit 9bd3b700c4
9 changed files with 326 additions and 17 deletions

View File

@@ -28,7 +28,7 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_roles, require_user from redaktionskern.auth.deps import get_db, require_roles, require_user
from redaktionskern.auth.models import Role, User from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
from .models import AuditEintrag from .models import AuditEintrag
@@ -88,6 +88,29 @@ class AuditLogPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url=f"/{self.name}")] return [NavEntry(label=self.title, url=f"/{self.name}")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: die letzten fünf Audit-Einträge (nur Admins)."""
if user.role != Role.ADMIN.value:
return []
with self.context.session_factory() as db:
letzte = db.scalars(
select(AuditEintrag).order_by(AuditEintrag.id.desc()).limit(5)
).all()
zeilen = tuple(
f"{e.erstellt_am.strftime('%d.%m. %H:%M') if e.erstellt_am else ''}"
f" · {e.actor_name or 'System'}: {e.action}"
for e in letzte
)
return [
DashboardKarte(
titel="Letzte Audit-Log-Einträge",
zeilen=zeilen,
beschreibung="" if zeilen else "Noch keine Ereignisse protokolliert.",
link_url="/audit-log",
link_label="Zum Audit-Log",
)
]
def on_load(self, context) -> None: def on_load(self, context) -> None:
super().on_load(context) super().on_load(context)
# Jinja-Filter für die JSON-Anzeige in der Admin-Ansicht. # Jinja-Filter für die JSON-Anzeige in der Admin-Ansicht.

View File

@@ -25,7 +25,7 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_user from redaktionskern.auth.deps import get_db, require_user
from redaktionskern.auth.models import User from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
from .adapter import BenachrichtigungsAdapter, adapter_aus_konfiguration from .adapter import BenachrichtigungsAdapter, adapter_aus_konfiguration
from .konfiguration import aus_umgebung from .konfiguration import aus_umgebung
@@ -80,6 +80,28 @@ class BenachrichtigungPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/benachrichtigung")] return [NavEntry(label=self.title, url="/benachrichtigung")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: Anzahl ungelesener In-App-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 [
DashboardKarte(
titel="Ungelesene Benachrichtigungen",
wert=str(ungelesen),
beschreibung="Nachrichten in deinem Posteingang",
link_url="/benachrichtigung",
link_label="Zum Posteingang",
ton="warnung" if ungelesen else "ok",
)
]
# ---------------- Öffentliche Plugin-API ---------------- # ---------------- Öffentliche Plugin-API ----------------
async def send_notification( async def send_notification(

View File

@@ -33,12 +33,12 @@ import logging
import os import os
from fastapi import Depends, Form, Request from fastapi import Depends, Form, Request
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_user from redaktionskern.auth.deps import get_db, require_user
from redaktionskern.auth.models import User from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
from .bgg import BggPruefClient from .bgg import BggPruefClient
from .ki_pruefung import KiPruefClient, lade_konfiguration from .ki_pruefung import KiPruefClient, lade_konfiguration
@@ -87,6 +87,36 @@ class DedupPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/dedup")] return [NavEntry(label=self.title, url="/dedup")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: offene Dedup-Konflikte (nur Redaktion)."""
from redaktionskern.auth.models import Role
from redaktionskern.db import Base
if user.role not in (Role.ADMIN.value, Role.REDAKTEUR.value):
return []
tabelle = Base.metadata.tables.get("planungsliste")
if tabelle is None:
return []
with self.context.session_factory() as db:
offene = db.scalar(
select(func.count())
.select_from(tabelle)
.where(
tabelle.c.pruefung.is_not(None),
tabelle.c.status != "abgeschlossen",
)
) or 0
return [
DashboardKarte(
titel="Offene Dedup-Konflikte",
wert=str(offene),
beschreibung="Planungseinträge mit Prüfhinweis",
link_url="/planung",
link_label="Zur Planung",
ton="warnung" if offene else "ok",
)
]
def on_load(self, context) -> None: def on_load(self, context) -> None:
super().on_load(context) super().on_load(context)
self._bgg_aktiv = os.environ.get("SPIELE_DEDUP_BGG_AKTIV", "1").strip() != "0" self._bgg_aktiv = os.environ.get("SPIELE_DEDUP_BGG_AKTIV", "1").strip() != "0"

View File

@@ -36,7 +36,13 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_roles, require_user from redaktionskern.auth.deps import get_db, require_roles, require_user
from redaktionskern.auth.models import Role, User from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext from redaktionskern.contracts import (
BasePlugin,
DashboardKarte,
Migration,
NavEntry,
PluginContext,
)
# Bewusst `from .dienst import …` (wie `from .models import …` überall sonst): # Bewusst `from .dienst import …` (wie `from .models import …` überall sonst):
# `from . import dienst` funktioniert unter dem dateibasierten Plugin-Loader # `from . import dienst` funktioniert unter dem dateibasierten Plugin-Loader
@@ -116,6 +122,39 @@ class ErinnerungPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/erinnerung")] return [NavEntry(label=self.title, url="/erinnerung")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: die nächsten Redaktionsschlüsse (für alle Rollen)."""
from .dienst import heute
with self.context.session_factory() as db:
ausgaben = db.scalars(
select(Ausgabe)
.where(Ausgabe.redaktionsschluss >= heute())
.order_by(Ausgabe.redaktionsschluss)
.limit(3)
).all()
if not ausgaben:
return [
DashboardKarte(
titel="Nächste Redaktionsschlüsse",
beschreibung="Aktuell ist kein Redaktionsschluss geplant.",
link_url="/erinnerung",
)
]
zeilen = tuple(
f"{a.redaktionsschluss.strftime('%d.%m.%Y')}{a.name}"
for a in ausgaben
)
return [
DashboardKarte(
titel="Nächste Redaktionsschlüsse",
wert=str(len(ausgaben)),
zeilen=zeilen,
link_url="/erinnerung",
link_label="Alle Ausgaben",
)
]
def on_load(self, context: PluginContext) -> None: def on_load(self, context: PluginContext) -> None:
super().on_load(context) super().on_load(context)
import os import os

View File

@@ -35,7 +35,13 @@ from sqlalchemy import func, or_, select
from redaktionskern.auth.deps import require_roles, require_user from redaktionskern.auth.deps import require_roles, require_user
from redaktionskern.auth.models import Role, User from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext from redaktionskern.contracts import (
BasePlugin,
DashboardKarte,
Migration,
NavEntry,
PluginContext,
)
from .bgg import BggClient, BggFehler from .bgg import BggClient, BggFehler
from .models import Neuheit, QuellenStatus from .models import Neuheit, QuellenStatus
@@ -394,6 +400,43 @@ class NeuheitenPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/neuheiten")] return [NavEntry(label=self.title, url="/neuheiten")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: Neuheiten-Übersicht (Redaktion) + System-Status (Admin)."""
if user.role not in (Role.ADMIN.value, Role.REDAKTEUR.value):
return []
with self.context.session_factory() as db:
neu = db.scalar(
select(func.count())
.select_from(Neuheit)
.where(Neuheit.status == "neuheit")
) or 0
karten = [
DashboardKarte(
titel="Neuheiten",
wert=str(neu),
beschreibung="Offene Einträge in der Neuheitenliste",
link_url="/neuheiten",
link_label="Zu den Neuheiten",
ton="ok" if neu else "neutral",
)
]
if user.role == Role.ADMIN.value:
token = self._aktives_bgg_token()
sync_aktiv = self._scheduler is not None and self._scheduler.running
karten.append(
DashboardKarte(
titel="System-Status",
zeilen=(
f"BGG-API-Token: {'konfiguriert' if token else 'fehlt'}",
f"Hintergrund-Sync: {'aktiv' if sync_aktiv else 'aus'}",
),
ton="ok" if token else "fehler",
link_url="/neuheiten",
link_label="Neuheiten synchronisieren",
)
)
return karten
def on_load(self, context: PluginContext) -> None: def on_load(self, context: PluginContext) -> None:
super().on_load(context) super().on_load(context)
import os import os

View File

@@ -33,7 +33,7 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import AccessDenied, get_db, require_user from redaktionskern.auth.deps import AccessDenied, get_db, require_user
from redaktionskern.auth.models import Role, User from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
from redaktionskern.db import Base from redaktionskern.db import Base
from .models import ( from .models import (
@@ -134,6 +134,55 @@ class PlanungPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/planung")] return [NavEntry(label=self.title, url="/planung")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: Planungs-Stats — Rezensenten ihre eigenen, die
Redaktion die Gesamtzahlen je Status."""
with self.context.session_factory() as db:
zaehler = dict(
db.execute(
select(Planungseintrag.status, func.count())
.group_by(Planungseintrag.status)
).all()
)
if user.role in REDAKTION:
return [
DashboardKarte(
titel="Planung",
wert=str(zaehler.get("offen", 0)),
beschreibung="offene Einträge",
zeilen=tuple(
f"{STATUS_ANZEIGE[s]}: {zaehler.get(s, 0)}"
for s in GUELTIGE_STATUS
),
link_url="/planung",
link_label="Zur Planung",
ton="neutral",
)
]
eigene = dict(
db.execute(
select(Planungseintrag.status, func.count())
.where(Planungseintrag.rezensent_id == user.id)
.group_by(Planungseintrag.status)
).all()
)
return [
DashboardKarte(
titel="Meine offenen Planungseinträge",
wert=str(eigene.get("offen", 0)),
link_url="/planung?status=offen",
link_label="Anzeigen",
ton="neutral",
),
DashboardKarte(
titel="Meine Einträge in Bearbeitung",
wert=str(eigene.get("in_bearbeitung", 0)),
link_url="/planung?status=in_bearbeitung",
link_label="Anzeigen",
ton="neutral",
),
]
# ---------- Hilfsfunktionen ---------- # ---------- Hilfsfunktionen ----------
@staticmethod @staticmethod

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_current_user, get_db, require_roles, require_user from redaktionskern.auth.deps import get_current_user, get_db, require_roles, require_user
from redaktionskern.auth.models import ROLEN, Role, User from redaktionskern.auth.models import ROLEN, Role, User
from redaktionskern.auth.security import hash_password, verify_password from redaktionskern.auth.security import hash_password, verify_password
from redaktionskern.contracts import DashboardKarte
router = APIRouter() router = APIRouter()
admin_router = APIRouter( admin_router = APIRouter(
@@ -24,9 +25,44 @@ def _render(request: Request, name: str, status_code: int = 200, **kontext):
# ---------------- Dashboard ---------------- # ---------------- Dashboard ----------------
def _plugin_dashboard_karten(request: Request, user: User) -> list[DashboardKarte]:
"""Sammelt die Dashboard-Karten aller geladenen Plugins ein.
Fachlogik bleibt in den Plugins (Plugin-Vertrag) — der Kern ruft nur
den optionalen Hook auf und duldet Fehler einzelner Plugins.
"""
karten: list[DashboardKarte] = []
for plugin in request.app.state.registry.all():
try:
karten.extend(plugin.dashboard_karten(user))
except Exception: # pragma: no cover - defensiv
import logging
logging.getLogger("redaktionskern").exception(
"Dashboard-Karten von Plugin %r fehlgeschlagen.", plugin.name
)
return karten
@router.get("/") @router.get("/")
def dashboard(request: Request, user: User = Depends(require_user)): def dashboard(
return _render(request, "dashboard.html", user=user) request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
karten = _plugin_dashboard_karten(request, user)
if user.role == Role.ADMIN.value:
benutzer_anzahl = db.scalar(select(func.count()).select_from(User)) or 0
karten.append(
DashboardKarte(
titel="Benutzer",
wert=str(benutzer_anzahl),
beschreibung="Angelegte Benutzerkonten",
link_url="/admin/benutzer",
link_label="Benutzerverwaltung",
)
)
return _render(request, "dashboard.html", user=user, karten=karten)
# ---------------- Login / Logout ---------------- # ---------------- Login / Logout ----------------

View File

@@ -33,6 +33,24 @@ class NavEntry:
url: str url: str
@dataclass(frozen=True)
class DashboardKarte:
"""Eine Karte auf dem Dashboard (von Plugins beigesteuert).
`ton` steuert die Farbgebung: neutral | ok | warnung | fehler.
`zeilen` sind optionale Kurzzeilen unter dem Hauptwert (z. B. die
nächsten Redaktionsschlüsse).
"""
titel: str
wert: str = ""
beschreibung: str = ""
zeilen: tuple[str, ...] = ()
link_url: str | None = None
link_label: str = "Öffnen"
ton: str = "neutral"
@dataclass(frozen=True) @dataclass(frozen=True)
class Migration: class Migration:
"""Eine Schema-Migration des Plugins.""" """Eine Schema-Migration des Plugins."""
@@ -88,6 +106,15 @@ class BasePlugin:
"""Einträge für die Hauptnavigation (leer = keiner).""" """Einträge für die Hauptnavigation (leer = keiner)."""
return [] return []
def dashboard_karten(self, user) -> list[DashboardKarte]:
"""Dashboard-Karten des Plugins für den angemeldeten Benutzer.
Das Plugin entscheidet selbst anhand von `user.role`, welche
Karten es zeigt (leer = keine). Fehler werden vom Kern geduldet
und übersprungen, damit eine defekte Karte nie das Dashboard bricht.
"""
return []
def templates_dir(self) -> Path | None: def templates_dir(self) -> Path | None:
"""Template-Ordner des Plugins (<Paket>/templates), falls vorhanden.""" """Template-Ordner des Plugins (<Paket>/templates), falls vorhanden."""
modul = sys.modules.get(type(self).__module__) modul = sys.modules.get(type(self).__module__)

View File

@@ -1,17 +1,56 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}&Uuml;bersicht &mdash; {{ app_name }}{% endblock %} {% block titel %}&Uuml;bersicht &mdash; {{ app_name }}{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-1">Willkommen, {{ user.display_name }}!</h1> <h1 class="text-2xl font-bold text-slate-900 mb-1">Willkommen, {{ user.display_name }}!</h1>
<p class="text-slate-600 mb-6"> <p class="text-slate-600 mb-6">
Rolle: <span class="font-medium">{{ rollen_anzeige[user.role] or user.role }}</span> Rolle: <span class="font-medium">{{ rollen_anzeige[user.role] or user.role }}</span>
</p> </p>
<section class="mb-8"> {% if karten %}
<h2 class="text-lg font-semibold mb-1">Geladene Plugins</h2> <section aria-label="Übersicht" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 mb-8">
<p class="text-sm text-slate-500 mb-3"> {% for karte in karten %}
Der Kern ist schlank &mdash; alle Funktionen kommen aus Plugins. {% set ton_rand = {
</p> 'neutral': 'border-slate-200',
<div class="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100"> 'ok': 'border-emerald-200',
'warnung': 'border-amber-300',
'fehler': 'border-red-300',
}[karte.ton] | default('border-slate-200') %}
{% set ton_wert = {
'neutral': 'text-slate-900',
'ok': 'text-emerald-700',
'warnung': 'text-amber-700',
'fehler': 'text-red-700',
}[karte.ton] | default('text-slate-900') %}
<div class="bg-white rounded-xl border {{ ton_rand }} shadow-sm p-4 flex flex-col">
<h2 class="text-base font-semibold text-slate-800 mb-1">{{ karte.titel }}</h2>
{% if karte.wert %}
<p class="text-3xl font-bold {{ ton_wert }} leading-tight mb-1">{{ karte.wert }}</p>
{% endif %}
{% if karte.beschreibung %}
<p class="text-sm text-slate-500 mb-1">{{ karte.beschreibung }}</p>
{% endif %}
{% if karte.zeilen %}
<ul class="text-sm text-slate-600 space-y-0.5 mt-1">
{% for zeile in karte.zeilen %}
<li class="truncate" title="{{ zeile }}">{{ zeile }}</li>
{% endfor %}
</ul>
{% endif %}
{% if karte.link_url %}
<a href="{{ karte.link_url }}"
class="mt-auto pt-3 text-sm font-medium text-emerald-700 hover:text-emerald-900">{{ karte.link_label }} &rarr;</a>
{% endif %}
</div>
{% endfor %}
</section>
{% endif %}
{% if user.role == 'admin' %}
<details class="mb-8">
<summary class="text-sm text-slate-500 cursor-pointer hover:text-emerald-700">
Technik: geladene Plugins ({{ geladene_plugins|length }})
</summary>
<div class="mt-3 bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{% for plugin in geladene_plugins %} {% for plugin in geladene_plugins %}
<div class="px-4 py-3 flex flex-wrap items-baseline gap-x-3 gap-y-1"> <div class="px-4 py-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="font-medium">{{ plugin.title or plugin.name }}</span> <span class="font-medium">{{ plugin.title or plugin.name }}</span>
@@ -20,5 +59,6 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</section> </details>
{% endif %}
{% endblock %} {% endblock %}