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:
@@ -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.models import ROLEN, Role, User
|
||||
from redaktionskern.auth.security import hash_password, verify_password
|
||||
from redaktionskern.contracts import DashboardKarte
|
||||
|
||||
router = APIRouter()
|
||||
admin_router = APIRouter(
|
||||
@@ -24,9 +25,44 @@ def _render(request: Request, name: str, status_code: int = 200, **kontext):
|
||||
|
||||
# ---------------- 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("/")
|
||||
def dashboard(request: Request, user: User = Depends(require_user)):
|
||||
return _render(request, "dashboard.html", user=user)
|
||||
def dashboard(
|
||||
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 ----------------
|
||||
|
||||
@@ -33,6 +33,24 @@ class NavEntry:
|
||||
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)
|
||||
class Migration:
|
||||
"""Eine Schema-Migration des Plugins."""
|
||||
@@ -88,6 +106,15 @@ class BasePlugin:
|
||||
"""Einträge für die Hauptnavigation (leer = keiner)."""
|
||||
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:
|
||||
"""Template-Ordner des Plugins (<Paket>/templates), falls vorhanden."""
|
||||
modul = sys.modules.get(type(self).__module__)
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titel %}Übersicht — {{ app_name }}{% endblock %}
|
||||
{% 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">
|
||||
Rolle: <span class="font-medium">{{ rollen_anzeige[user.role] or user.role }}</span>
|
||||
</p>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-lg font-semibold mb-1">Geladene Plugins</h2>
|
||||
<p class="text-sm text-slate-500 mb-3">
|
||||
Der Kern ist schlank — alle Funktionen kommen aus Plugins.
|
||||
</p>
|
||||
<div class="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
|
||||
{% if karten %}
|
||||
<section aria-label="Übersicht" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 mb-8">
|
||||
{% for karte in karten %}
|
||||
{% set ton_rand = {
|
||||
'neutral': 'border-slate-200',
|
||||
'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 }} →</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 %}
|
||||
<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>
|
||||
@@ -20,5 +59,6 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user