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:
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ----------------
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|||||||
@@ -1,17 +1,56 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block titel %}Übersicht — {{ app_name }}{% endblock %}
|
{% block titel %}Übersicht — {{ 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 — 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 }} →</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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user