diff --git a/plugins/audit-log/__init__.py b/plugins/audit-log/__init__.py index 0c0143c..177d63f 100644 --- a/plugins/audit-log/__init__.py +++ b/plugins/audit-log/__init__.py @@ -28,7 +28,7 @@ from sqlalchemy.orm import Session from redaktionskern.auth.deps import get_db, require_roles, require_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 @@ -88,6 +88,29 @@ class AuditLogPlugin(BasePlugin): def navigation(self) -> list[NavEntry]: 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: super().on_load(context) # Jinja-Filter für die JSON-Anzeige in der Admin-Ansicht. diff --git a/plugins/benachrichtigung/__init__.py b/plugins/benachrichtigung/__init__.py index 32f6291..9c6a12d 100644 --- a/plugins/benachrichtigung/__init__.py +++ b/plugins/benachrichtigung/__init__.py @@ -25,7 +25,7 @@ from sqlalchemy.orm import Session from redaktionskern.auth.deps import get_db, require_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 .konfiguration import aus_umgebung @@ -80,6 +80,28 @@ class BenachrichtigungPlugin(BasePlugin): def navigation(self) -> list[NavEntry]: 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 ---------------- async def send_notification( diff --git a/plugins/dedup/__init__.py b/plugins/dedup/__init__.py index 79746e7..3f2e061 100644 --- a/plugins/dedup/__init__.py +++ b/plugins/dedup/__init__.py @@ -33,12 +33,12 @@ import logging import os from fastapi import Depends, Form, Request -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from redaktionskern.auth.deps import get_db, require_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 .ki_pruefung import KiPruefClient, lade_konfiguration @@ -87,6 +87,36 @@ class DedupPlugin(BasePlugin): def navigation(self) -> list[NavEntry]: 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: super().on_load(context) self._bgg_aktiv = os.environ.get("SPIELE_DEDUP_BGG_AKTIV", "1").strip() != "0" diff --git a/plugins/erinnerung/__init__.py b/plugins/erinnerung/__init__.py index eac98b6..8331f35 100644 --- a/plugins/erinnerung/__init__.py +++ b/plugins/erinnerung/__init__.py @@ -36,7 +36,13 @@ from sqlalchemy.orm import Session from redaktionskern.auth.deps import get_db, require_roles, require_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): # `from . import dienst` funktioniert unter dem dateibasierten Plugin-Loader @@ -116,6 +122,39 @@ class ErinnerungPlugin(BasePlugin): def navigation(self) -> list[NavEntry]: 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: super().on_load(context) import os diff --git a/plugins/neuheiten/__init__.py b/plugins/neuheiten/__init__.py index 77728ca..9223c20 100644 --- a/plugins/neuheiten/__init__.py +++ b/plugins/neuheiten/__init__.py @@ -35,7 +35,13 @@ from sqlalchemy import func, or_, select from redaktionskern.auth.deps import require_roles, require_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 .models import Neuheit, QuellenStatus @@ -394,6 +400,43 @@ class NeuheitenPlugin(BasePlugin): def navigation(self) -> list[NavEntry]: 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: super().on_load(context) import os diff --git a/plugins/planung/__init__.py b/plugins/planung/__init__.py index 1c376e6..a163322 100644 --- a/plugins/planung/__init__.py +++ b/plugins/planung/__init__.py @@ -33,7 +33,7 @@ from sqlalchemy.orm import Session from redaktionskern.auth.deps import AccessDenied, get_db, require_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 .models import ( @@ -134,6 +134,55 @@ class PlanungPlugin(BasePlugin): def navigation(self) -> list[NavEntry]: 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 ---------- @staticmethod diff --git a/src/redaktionskern/auth/routen.py b/src/redaktionskern/auth/routen.py index f4b3b36..93e2278 100644 --- a/src/redaktionskern/auth/routen.py +++ b/src/redaktionskern/auth/routen.py @@ -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 ---------------- diff --git a/src/redaktionskern/contracts.py b/src/redaktionskern/contracts.py index 5f6b5db..3ed0b76 100644 --- a/src/redaktionskern/contracts.py +++ b/src/redaktionskern/contracts.py @@ -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 (/templates), falls vorhanden.""" modul = sys.modules.get(type(self).__module__) diff --git a/src/redaktionskern/templates/dashboard.html b/src/redaktionskern/templates/dashboard.html index 3a52be4..23b3010 100644 --- a/src/redaktionskern/templates/dashboard.html +++ b/src/redaktionskern/templates/dashboard.html @@ -1,17 +1,56 @@ {% extends "base.html" %} {% block titel %}Übersicht — {{ app_name }}{% endblock %} {% block inhalt %} -

Willkommen, {{ user.display_name }}!

+

Willkommen, {{ user.display_name }}!

Rolle: {{ rollen_anzeige[user.role] or user.role }}

-
-

Geladene Plugins

-

- Der Kern ist schlank — alle Funktionen kommen aus Plugins. -

-
+{% if karten %} +
+ {% 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') %} +
+

{{ karte.titel }}

+ {% if karte.wert %} +

{{ karte.wert }}

+ {% endif %} + {% if karte.beschreibung %} +

{{ karte.beschreibung }}

+ {% endif %} + {% if karte.zeilen %} +
    + {% for zeile in karte.zeilen %} +
  • {{ zeile }}
  • + {% endfor %} +
+ {% endif %} + {% if karte.link_url %} + {{ karte.link_label }} → + {% endif %} +
+ {% endfor %} +
+{% endif %} + +{% if user.role == 'admin' %} +
+ + Technik: geladene Plugins ({{ geladene_plugins|length }}) + +
{% for plugin in geladene_plugins %}
{{ plugin.title or plugin.name }} @@ -20,5 +59,6 @@
{% endfor %}
-
+ +{% endif %} {% endblock %}