Phase 1: Kern mit Plugin-System, Auth/Rollen, Migrations, Plugin-Stubs
- redaktionskern (src/): schlanker Kern — App-Fabrik, Plugin-Loader (Entry-Points + plugins/-Verzeichnis), Migrations-Laufzeit (schema_migrations pro Plugin, SQLite-/Postgres-portabel), Auth mit Argon2id + Session-Cookies, Rollen admin/redakteur/rezensent, Benutzerverwaltung für Admins - plugins/: 8 ladbare Stubs (neuheiten, dedup, planung, archiv, erinnerung, benachrichtigung, audit-log, export) nach Plugin-Vertrag - Frontend: Jinja2 + Tailwind (CDN) + HTMX + Alpine.js, UI deutsch - Tests: 28 pytest-Fälle (Loader, Lifecycle, Entry-Points, Migrations- Idempotenz, Login/Logout, Rollen-Zugriff, Benutzerverwaltung) - Docker/Podman: Compose (Traefik-Labels) + Dockerfile (uv) - README.md mit Setup-Anleitung
This commit is contained in:
8
src/redaktionskern/__init__.py
Normal file
8
src/redaktionskern/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Spiele-Redaktion — schlanker Kern mit Plugin-Architektur.
|
||||
|
||||
Der Kern stellt nur Infrastruktur bereit: App-Fabrik, Datenbank,
|
||||
Migrations-Laufzeit, Plugin-Loader und Authentifizierung.
|
||||
Fachliche Funktionen liegen ausschließlich in Plugins.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
160
src/redaktionskern/app.py
Normal file
160
src/redaktionskern/app.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""App-Fabrik: baut die FastAPI-Anwendung aus Kern und Plugins zusammen.
|
||||
|
||||
Ablauf bei create_app():
|
||||
1. Plugins entdecken (Entry-Points + plugins/-Verzeichnis)
|
||||
2. Jinja-Umgebung aufbauen (Kern-Templates + Plugin-Templates)
|
||||
3. Engine/Session erstellen, Kern- und Plugin-Migrationen ausführen
|
||||
4. Plugin-Lifecycle on_load() aufrufen, Routen registrieren
|
||||
5. Initialen Admin anlegen, falls die Benutzertabelle leer ist
|
||||
|
||||
Beim Herunterfahren (Lifespan) werden on_unload() und die Engine freigegeben.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
|
||||
from sqlalchemy import func, select
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from redaktionskern.auth import routen as auth_routen
|
||||
from redaktionskern.auth.deps import AccessDenied, NotAuthenticated
|
||||
from redaktionskern.auth.models import ROLLEN_ANZEIGE, Role, User
|
||||
from redaktionskern.auth.security import hash_password
|
||||
from redaktionskern.config import Settings
|
||||
from redaktionskern.contracts import Migration, PluginContext
|
||||
from redaktionskern.db import make_engine, make_session_factory
|
||||
from redaktionskern.migrationen import run_migrations
|
||||
from redaktionskern.plugin_loader import discover_plugins
|
||||
|
||||
CORE_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
|
||||
|
||||
|
||||
def _kern_migrationen() -> list[Migration]:
|
||||
def benutzer_tabelle(conn) -> None:
|
||||
User.__table__.create(conn, checkfirst=True)
|
||||
|
||||
return [Migration(version="0001_benutzer", up=benutzer_tabelle)]
|
||||
|
||||
|
||||
def _initialen_admin_anlegen(session_factory, settings: Settings) -> None:
|
||||
with session_factory() as db:
|
||||
vorhanden = db.scalar(select(func.count()).select_from(User))
|
||||
if vorhanden:
|
||||
return
|
||||
db.add(
|
||||
User(
|
||||
username="admin",
|
||||
display_name="Administration",
|
||||
password_hash=hash_password(settings.initial_admin_password),
|
||||
role=Role.ADMIN.value,
|
||||
active=True,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
settings = settings or Settings()
|
||||
|
||||
# 1) Plugins entdecken
|
||||
registry = discover_plugins(settings.plugins_dir, settings.entry_point_group)
|
||||
plugins = registry.all()
|
||||
|
||||
# 2) Jinja-Umgebung: Kern zuerst, dann Plugin-Templates
|
||||
template_dirs = [CORE_TEMPLATE_DIR]
|
||||
template_dirs += [d for d in (p.templates_dir() for p in plugins) if d]
|
||||
env = Environment(
|
||||
loader=ChoiceLoader([FileSystemLoader(str(d)) for d in template_dirs]),
|
||||
autoescape=select_autoescape(default=True, default_for_string=True),
|
||||
)
|
||||
templates = Jinja2Templates(env=env)
|
||||
|
||||
# 3) Datenbank + Migrationen
|
||||
engine = make_engine(settings.database_url)
|
||||
session_factory = make_session_factory(engine)
|
||||
context = PluginContext(
|
||||
engine=engine,
|
||||
session_factory=session_factory,
|
||||
settings=settings,
|
||||
templates=templates,
|
||||
)
|
||||
|
||||
with engine.begin() as conn:
|
||||
run_migrations(conn, "core", _kern_migrationen())
|
||||
for plugin in plugins:
|
||||
with engine.begin() as conn:
|
||||
run_migrations(conn, plugin.name, plugin.migrations())
|
||||
|
||||
# 4) Lifecycle + Router
|
||||
for plugin in plugins:
|
||||
plugin.on_load(context)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
yield
|
||||
for plugin in reversed(plugins):
|
||||
plugin.on_unload()
|
||||
engine.dispose()
|
||||
|
||||
app = FastAPI(
|
||||
title="Spiele-Redaktion",
|
||||
lifespan=lifespan,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
app.state.settings = settings
|
||||
app.state.engine = engine
|
||||
app.state.session_factory = session_factory
|
||||
app.state.templates = templates
|
||||
app.state.registry = registry
|
||||
|
||||
templates.env.globals["app_name"] = "Spiele-Redaktion"
|
||||
templates.env.globals["rollen_anzeige"] = ROLLEN_ANZEIGE
|
||||
templates.env.globals["plugin_nav"] = [
|
||||
{"label": eintrag.label, "url": eintrag.url}
|
||||
for plugin in plugins
|
||||
for eintrag in plugin.navigation()
|
||||
]
|
||||
templates.env.globals["geladene_plugins"] = plugins
|
||||
|
||||
app.include_router(auth_routen.router)
|
||||
app.include_router(auth_routen.admin_router)
|
||||
for plugin in plugins:
|
||||
app.include_router(plugin.router)
|
||||
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.session_secret,
|
||||
max_age=settings.session_dauer_sekunden,
|
||||
same_site="lax",
|
||||
)
|
||||
|
||||
def _aktueller_benutzer_aus_session(request: Request) -> User | None:
|
||||
user_id = request.session.get("user_id")
|
||||
if user_id is None:
|
||||
return None
|
||||
with session_factory() as db:
|
||||
return db.get(User, user_id)
|
||||
|
||||
@app.exception_handler(NotAuthenticated)
|
||||
async def nicht_angemeldet_handler(_: Request, __: NotAuthenticated):
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
@app.exception_handler(AccessDenied)
|
||||
async def kein_zugriff_handler(request: Request, _: AccessDenied):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="403.html",
|
||||
context={"user": _aktueller_benutzer_aus_session(request)},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# 5) Initialer Admin
|
||||
_initialen_admin_anlegen(session_factory, settings)
|
||||
return app
|
||||
1
src/redaktionskern/auth/__init__.py
Normal file
1
src/redaktionskern/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Authentifizierung und Autorisierung (Kern-Infrastruktur)."""
|
||||
48
src/redaktionskern/auth/deps.py
Normal file
48
src/redaktionskern/auth/deps.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""FastAPI-Abhängigkeiten für Authentifizierung und Rollen."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from redaktionskern.auth.models import User
|
||||
|
||||
|
||||
class NotAuthenticated(Exception):
|
||||
"""Benutzer ist nicht (mehr) angemeldet → Weiterleitung zum Login."""
|
||||
|
||||
|
||||
class AccessDenied(Exception):
|
||||
"""Benutzer hat die nötige Rolle nicht → 403-Seite."""
|
||||
|
||||
|
||||
def get_db(request: Request):
|
||||
"""Stellt eine Datenbank-Session pro Anfrage bereit."""
|
||||
db: Session = request.app.state.session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
|
||||
user_id = request.session.get("user_id")
|
||||
if user_id is None:
|
||||
return None
|
||||
return db.get(User, user_id)
|
||||
|
||||
|
||||
def require_user(user: User | None = Depends(get_current_user)) -> User:
|
||||
if user is None or not user.active:
|
||||
raise NotAuthenticated
|
||||
return user
|
||||
|
||||
|
||||
def require_roles(*roles: str):
|
||||
"""Abhängigkeits-Fabrik: verlangt eine der angegebenen Rollen."""
|
||||
|
||||
def abhaengigkeit(user: User = Depends(require_user)) -> User:
|
||||
if user.role not in roles:
|
||||
raise AccessDenied(f"Rolle '{user.role}' hat keinen Zugriff.")
|
||||
return user
|
||||
|
||||
return abhaengigkeit
|
||||
39
src/redaktionskern/auth/models.py
Normal file
39
src/redaktionskern/auth/models.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Benutzer-Model des Kerns."""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from redaktionskern.db import Base
|
||||
|
||||
|
||||
class Role(str, enum.Enum):
|
||||
ADMIN = "admin"
|
||||
REDAKTEUR = "redakteur"
|
||||
REZENSENT = "rezensent"
|
||||
|
||||
|
||||
ROLEN: list[str] = [rolle.value for rolle in Role]
|
||||
|
||||
ROLLEN_ANZEIGE: dict[str, str] = {
|
||||
Role.ADMIN.value: "Administrator",
|
||||
Role.REDAKTEUR.value: "Redakteur",
|
||||
Role.REZENSENT.value: "Rezensent",
|
||||
}
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(200))
|
||||
password_hash: Mapped[str] = mapped_column(String(300))
|
||||
role: Mapped[str] = mapped_column(String(20), default=Role.REDAKTEUR.value)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
232
src/redaktionskern/auth/routen.py
Normal file
232
src/redaktionskern/auth/routen.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Routen: Login/Logout, Dashboard und Benutzerverwaltung (nur Admin)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
admin_router = APIRouter(
|
||||
prefix="/admin", dependencies=[Depends(require_roles(Role.ADMIN.value))]
|
||||
)
|
||||
|
||||
|
||||
def _render(request: Request, name: str, status_code: int = 200, **kontext):
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request, name=name, context=kontext, status_code=status_code
|
||||
)
|
||||
|
||||
|
||||
# ---------------- Dashboard ----------------
|
||||
|
||||
@router.get("/")
|
||||
def dashboard(request: Request, user: User = Depends(require_user)):
|
||||
return _render(request, "dashboard.html", user=user)
|
||||
|
||||
|
||||
# ---------------- Login / Logout ----------------
|
||||
|
||||
@router.get("/login")
|
||||
def login_form(
|
||||
request: Request, user: User | None = Depends(get_current_user)
|
||||
):
|
||||
if user is not None:
|
||||
return RedirectResponse("/", status_code=303)
|
||||
return _render(request, "login.html", user=None, fehler=None, username="")
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
username = username.strip()
|
||||
user = db.scalar(select(User).where(User.username == username))
|
||||
fehler: str | None
|
||||
if user is None or not verify_password(user.password_hash, password):
|
||||
fehler = "Benutzername oder Passwort ist falsch."
|
||||
elif not user.active:
|
||||
fehler = "Dieses Konto ist deaktiviert."
|
||||
else:
|
||||
fehler = None
|
||||
if fehler is not None:
|
||||
return _render(
|
||||
request, "login.html", user=None, fehler=fehler, username=username
|
||||
)
|
||||
request.session["user_id"] = user.id
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
|
||||
# ---------------- Benutzerverwaltung (Admin) ----------------
|
||||
|
||||
def _formular_werte(benutzer: User | None) -> dict:
|
||||
if benutzer is None:
|
||||
return {"username": "", "display_name": "", "role": Role.REDAKTEUR.value}
|
||||
return {
|
||||
"username": benutzer.username,
|
||||
"display_name": benutzer.display_name,
|
||||
"role": benutzer.role,
|
||||
}
|
||||
|
||||
|
||||
@admin_router.get("/benutzer")
|
||||
def benutzer_liste(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
benutzer = db.scalars(select(User).order_by(User.username)).all()
|
||||
return _render(
|
||||
request, "benutzer/liste.html", user=user, benutzer=benutzer, fehler=None
|
||||
)
|
||||
|
||||
|
||||
@admin_router.get("/benutzer/neu")
|
||||
def benutzer_neu_form(request: Request, user: User = Depends(require_user)):
|
||||
return _render(
|
||||
request,
|
||||
"benutzer/formular.html",
|
||||
user=user,
|
||||
aktion="neu",
|
||||
ziel=None,
|
||||
form=_formular_werte(None),
|
||||
fehler=None,
|
||||
)
|
||||
|
||||
|
||||
def _validiere_neuen_benutzer(
|
||||
db: Session, username: str, role: str, password: str
|
||||
) -> str | None:
|
||||
if len(username) < 3:
|
||||
return "Der Benutzername muss mindestens 3 Zeichen lang sein."
|
||||
if db.scalar(select(User).where(User.username == username)) is not None:
|
||||
return "Dieser Benutzername ist bereits vergeben."
|
||||
if role not in ROLEN:
|
||||
return "Unbekannte Rolle."
|
||||
if len(password) < 8:
|
||||
return "Das Passwort muss mindestens 8 Zeichen lang sein."
|
||||
return None
|
||||
|
||||
|
||||
@admin_router.post("/benutzer/neu")
|
||||
def benutzer_neu(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
username: str = Form(...),
|
||||
display_name: str = Form(""),
|
||||
role: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
username = username.strip()
|
||||
fehler = _validiere_neuen_benutzer(db, username, role, password)
|
||||
if fehler is not None:
|
||||
return _render(
|
||||
request,
|
||||
"benutzer/formular.html",
|
||||
status_code=400,
|
||||
user=user,
|
||||
aktion="neu",
|
||||
ziel=None,
|
||||
form={"username": username, "display_name": display_name, "role": role},
|
||||
fehler=fehler,
|
||||
)
|
||||
db.add(
|
||||
User(
|
||||
username=username,
|
||||
display_name=display_name.strip() or username,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
active=True,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/benutzer", status_code=303)
|
||||
|
||||
|
||||
def _lade_oder_404(db: Session, benutzer_id: int) -> User:
|
||||
ziel = db.get(User, benutzer_id)
|
||||
if ziel is None:
|
||||
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden.")
|
||||
return ziel
|
||||
|
||||
|
||||
@admin_router.get("/benutzer/{benutzer_id}")
|
||||
def benutzer_bearbeiten_form(
|
||||
benutzer_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
ziel = _lade_oder_404(db, benutzer_id)
|
||||
return _render(
|
||||
request,
|
||||
"benutzer/formular.html",
|
||||
user=user,
|
||||
aktion="bearbeiten",
|
||||
ziel=ziel,
|
||||
form=_formular_werte(ziel),
|
||||
fehler=None,
|
||||
)
|
||||
|
||||
|
||||
@admin_router.post("/benutzer/{benutzer_id}")
|
||||
def benutzer_bearbeiten(
|
||||
benutzer_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
display_name: str = Form(""),
|
||||
role: str = Form(...),
|
||||
active: str = Form(""),
|
||||
password: str = Form(""),
|
||||
):
|
||||
ziel = _lade_oder_404(db, benutzer_id)
|
||||
aktiv = active == "on"
|
||||
fehler: str | None
|
||||
if role not in ROLEN:
|
||||
fehler = "Unbekannte Rolle."
|
||||
elif password and len(password) < 8:
|
||||
fehler = "Das Passwort muss mindestens 8 Zeichen lang sein."
|
||||
elif ziel.id == user.id and (role != Role.ADMIN.value or not aktiv):
|
||||
fehler = "Sie können Ihre eigene Rolle oder Ihren eigenen Status nicht ändern."
|
||||
else:
|
||||
fehler = None
|
||||
if fehler is not None:
|
||||
return _render(
|
||||
request,
|
||||
"benutzer/formular.html",
|
||||
status_code=400,
|
||||
user=user,
|
||||
aktion="bearbeiten",
|
||||
ziel=ziel,
|
||||
form={"username": ziel.username, "display_name": display_name, "role": role},
|
||||
fehler=fehler,
|
||||
)
|
||||
ziel.display_name = display_name.strip() or ziel.username
|
||||
ziel.role = role
|
||||
ziel.active = aktiv
|
||||
if password:
|
||||
ziel.password_hash = hash_password(password)
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/benutzer", status_code=303)
|
||||
|
||||
|
||||
@admin_router.get("/benutzern-anzahl")
|
||||
def benutzer_anzahl(db: Session = Depends(get_db)):
|
||||
"""Kleine Diagnose-Route für den Admin (zeigt, dass die DB lebt)."""
|
||||
anzahl = db.scalar(select(func.count()).select_from(User))
|
||||
return {"anzahl": anzahl}
|
||||
19
src/redaktionskern/auth/security.py
Normal file
19
src/redaktionskern/auth/security.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Passwort-Hashing mit Argon2id."""
|
||||
from __future__ import annotations
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password_hash: str, password: str) -> bool:
|
||||
try:
|
||||
_hasher.verify(password_hash, password)
|
||||
except (VerifyMismatchError, InvalidHashError):
|
||||
return False
|
||||
return True
|
||||
44
src/redaktionskern/config.py
Normal file
44
src/redaktionskern/config.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Konfiguration über Umgebungsvariablen mit sinnvollen Dev-Defaults."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
_PROJEKT_WURZEL = Path(__file__).resolve().parents[2]
|
||||
|
||||
ENTRY_POINT_GRUPPE = "spiele_redaktion.plugins"
|
||||
|
||||
|
||||
def _standard_plugins_verzeichnis() -> Path:
|
||||
umgebung = os.environ.get("SPIELE_PLUGINS_DIR")
|
||||
if umgebung:
|
||||
return Path(umgebung)
|
||||
lokal = _PROJEKT_WURZEL / "plugins"
|
||||
if lokal.is_dir():
|
||||
return lokal
|
||||
return Path("plugins")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
"""Laufzeit-Einstellungen. Alle Felder sind per Umgebungsvariable überschreibbar."""
|
||||
|
||||
database_url: str = field(
|
||||
default_factory=lambda: os.environ.get(
|
||||
"SPIELE_DATABASE_URL", "sqlite:///./data/spiele-redaktion.db"
|
||||
)
|
||||
)
|
||||
session_secret: str = field(
|
||||
default_factory=lambda: os.environ.get(
|
||||
"SPIELE_SESSION_SECRET", "dev-secret-nur-fuer-lokal-aendern"
|
||||
)
|
||||
)
|
||||
session_dauer_sekunden: int = field(
|
||||
default_factory=lambda: int(os.environ.get("SPIELE_SESSION_DAUER", "43200"))
|
||||
)
|
||||
initial_admin_password: str = field(
|
||||
default_factory=lambda: os.environ.get("SPIELE_INITIAL_ADMIN_PASSWORD", "admin")
|
||||
)
|
||||
plugins_dir: Path = field(default_factory=_standard_plugins_verzeichnis)
|
||||
entry_point_group: str = field(default_factory=lambda: ENTRY_POINT_GRUPPE)
|
||||
101
src/redaktionskern/contracts.py
Normal file
101
src/redaktionskern/contracts.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Der Plugin-Vertrag.
|
||||
|
||||
Ein Plugin ist eine Klasse, die von BasePlugin erbt, und ein Modulattribut
|
||||
`plugin` mit einer Instanz davon. Der Kern ruft beim Start `on_load(kontext)`
|
||||
auf, beim Herunterfahren `on_unload()`. Fachlogik gehört ausschließlich in
|
||||
Plugins — der Kern kennt keine Plugin-Inhalte.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from fastapi import APIRouter
|
||||
from sqlalchemy import Connection
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from redaktionskern.config import Settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NavEntry:
|
||||
"""Ein Eintrag in der Hauptnavigation."""
|
||||
|
||||
label: str
|
||||
url: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Migration:
|
||||
"""Eine Schema-Migration des Plugins."""
|
||||
|
||||
version: str
|
||||
up: Callable[[Connection], None]
|
||||
|
||||
|
||||
class PluginContext:
|
||||
"""Alles, was der Kern einem Plugin beim Laden übergibt."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: "Engine",
|
||||
session_factory: "sessionmaker",
|
||||
settings: "Settings",
|
||||
templates: "Jinja2Templates",
|
||||
) -> None:
|
||||
self.engine = engine
|
||||
self.session_factory = session_factory
|
||||
self.settings = settings
|
||||
self.templates = templates
|
||||
|
||||
|
||||
class BasePlugin:
|
||||
"""Basis-Klasse für alle Plugins (der Plugin-Vertrag).
|
||||
|
||||
Pflicht: die Klassenattribute `name`, `title`, `description`.
|
||||
Optional überschreibbar: migrations(), navigation(), templates_dir(),
|
||||
on_load(), on_unload().
|
||||
"""
|
||||
|
||||
name: ClassVar[str]
|
||||
title: ClassVar[str] = ""
|
||||
description: ClassVar[str] = ""
|
||||
version: ClassVar[str] = "0.1.0"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.router = APIRouter()
|
||||
self.context: PluginContext | None = None
|
||||
self.loaded = False
|
||||
|
||||
def migrations(self) -> list[Migration]:
|
||||
"""Eigene Schema-Migrationen des Plugins (leer = keine)."""
|
||||
return []
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
"""Einträge für die Hauptnavigation (leer = keiner)."""
|
||||
return []
|
||||
|
||||
def templates_dir(self) -> Path | None:
|
||||
"""Template-Ordner des Plugins (<Paket>/templates), falls vorhanden."""
|
||||
modul = sys.modules.get(type(self).__module__)
|
||||
datei = getattr(modul, "__file__", None) if modul else None
|
||||
if datei is None:
|
||||
return None
|
||||
verzeichnis = Path(datei).parent / "templates"
|
||||
return verzeichnis if verzeichnis.is_dir() else None
|
||||
|
||||
def on_load(self, context: PluginContext) -> None:
|
||||
"""Lifecycle-Hook: wird beim Start des Kerns aufgerufen."""
|
||||
self.context = context
|
||||
self.loaded = True
|
||||
|
||||
def on_unload(self) -> None:
|
||||
"""Lifecycle-Hook: wird beim Herunterfahren aufgerufen."""
|
||||
self.loaded = False
|
||||
32
src/redaktionskern/db.py
Normal file
32
src/redaktionskern/db.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Datenbank-Grundlagen: Engine, Session-Fabrik, deklarative Basis.
|
||||
|
||||
SQLite ist der Standard; über SPIELE_DATABASE_URL ist auch Postgres möglich.
|
||||
Alle Models nutzen portable Spaltentypen, damit Migrationen auf beiden
|
||||
Datenbanken laufen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Gemeinsame deklarative Basis für Kern- und Plugin-Models."""
|
||||
|
||||
|
||||
def make_engine(database_url: str) -> Engine:
|
||||
"""Erzeugt eine Engine; legt für SQLite das Elternverzeichnis an."""
|
||||
if database_url.startswith("sqlite"):
|
||||
pfad = database_url.split("///", 1)[-1]
|
||||
if pfad and pfad != ":memory:":
|
||||
Path(pfad).parent.mkdir(parents=True, exist_ok=True)
|
||||
# check_same_thread=False: FastAPI führt sync-Endpunkte im Threadpool aus.
|
||||
return create_engine(database_url, connect_args={"check_same_thread": False})
|
||||
return create_engine(database_url)
|
||||
|
||||
|
||||
def make_session_factory(engine: Engine) -> sessionmaker[Session]:
|
||||
return sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
|
||||
53
src/redaktionskern/migrationen.py
Normal file
53
src/redaktionskern/migrationen.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Migrations-Laufzeit.
|
||||
|
||||
Jedes Plugin (und der Kern selbst) liefert geordnete Migrationen als
|
||||
Liste von Migration(version, up). Der Stand wird pro Plugin in der
|
||||
Tabelle `schema_migrations` festgehalten. Die `up`-Funktionen bekommen
|
||||
eine SQLAlchemy-Connection und können bei Bedarf pro Dialekt verzweigen —
|
||||
so bleiben die Migrationen SQLite- und Postgres-tauglich.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from sqlalchemy import Column, Connection, DateTime, MetaData, Table, String, func, select
|
||||
|
||||
from redaktionskern.contracts import Migration
|
||||
|
||||
_metadata = MetaData()
|
||||
|
||||
schema_migrations = Table(
|
||||
"schema_migrations",
|
||||
_metadata,
|
||||
Column("plugin", String(100), primary_key=True),
|
||||
Column("version", String(200), primary_key=True),
|
||||
Column("applied_at", DateTime, nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
|
||||
def run_migrations(
|
||||
conn: Connection, plugin: str, migrations: list[Migration]
|
||||
) -> list[str]:
|
||||
"""Führt noch nicht angewendete Migrationen aus und gibt deren Versionen zurück.
|
||||
|
||||
Der Aufrufer ist für die Transaktion verantwortlich (engine.begin()).
|
||||
"""
|
||||
schema_migrations.create(conn, checkfirst=True)
|
||||
angewendet = {
|
||||
version
|
||||
for (version,) in conn.execute(
|
||||
select(schema_migrations.c.version).where(
|
||||
schema_migrations.c.plugin == plugin
|
||||
)
|
||||
)
|
||||
}
|
||||
neu_angewendet: list[str] = []
|
||||
for migration in sorted(migrations, key=lambda m: m.version):
|
||||
if migration.version in angewendet:
|
||||
continue
|
||||
migration.up(conn)
|
||||
conn.execute(
|
||||
schema_migrations.insert().values(plugin=plugin, version=migration.version)
|
||||
)
|
||||
neu_angewendet.append(migration.version)
|
||||
return neu_angewendet
|
||||
113
src/redaktionskern/plugin_loader.py
Normal file
113
src/redaktionskern/plugin_loader.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Plugin-Loader.
|
||||
|
||||
Plugins werden aus zwei Quellen geladen:
|
||||
1. Installierte Pakete mit Entry-Points der Gruppe `spiele_redaktion.plugins`
|
||||
(via importlib.metadata) — für später ausgelieferte Plugin-Pakete.
|
||||
2. Ein lokales `plugins/`-Verzeichnis — jeder Unterordner mit __init__.py
|
||||
ist ein Plugin (Modulattribut `plugin` = BasePlugin-Instanz).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import sys
|
||||
from importlib.metadata import entry_points
|
||||
from pathlib import Path
|
||||
|
||||
from redaktionskern.contracts import BasePlugin
|
||||
|
||||
ENTRY_POINT_GROUP = "spiele_redaktion.plugins"
|
||||
_MODUL_PRAEFIX = "spiele_redaktion_plugins"
|
||||
|
||||
|
||||
class PluginError(Exception):
|
||||
"""Fehler beim Laden oder Registrieren eines Plugins."""
|
||||
|
||||
|
||||
def _als_plugin(obj: object) -> BasePlugin:
|
||||
"""Akzeptiert eine Instanz oder eine Klasse (wird instanziert)."""
|
||||
if isinstance(obj, type) and issubclass(obj, BasePlugin):
|
||||
obj = obj()
|
||||
if not isinstance(obj, BasePlugin):
|
||||
raise PluginError(
|
||||
f"Erwartet wurde eine BasePlugin-Instanz, erhalten: {type(obj).__name__}"
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
def _aus_entry_points(group: str) -> list[BasePlugin]:
|
||||
plugins: list[BasePlugin] = []
|
||||
for entry_point in entry_points(group=group):
|
||||
try:
|
||||
plugins.append(_als_plugin(entry_point.load()))
|
||||
except PluginError:
|
||||
raise
|
||||
except Exception as exc: # Entry-Point kann nicht geladen werden
|
||||
raise PluginError(
|
||||
f"Entry-Point '{entry_point.name}' (Gruppe {group}) "
|
||||
f"konnte nicht geladen werden: {exc}"
|
||||
) from exc
|
||||
return plugins
|
||||
|
||||
|
||||
def _aus_verzeichnis(verzeichnis: Path) -> list[BasePlugin]:
|
||||
plugins: list[BasePlugin] = []
|
||||
if not verzeichnis.is_dir():
|
||||
return plugins
|
||||
for eintrag in sorted(verzeichnis.iterdir()):
|
||||
if not eintrag.is_dir() or eintrag.name.startswith(("_", ".")):
|
||||
continue
|
||||
init = eintrag / "__init__.py"
|
||||
if not init.is_file():
|
||||
continue
|
||||
modulname = f"{_MODUL_PRAEFIX}.{eintrag.name}"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
modulname, init, submodule_search_locations=[str(eintrag)]
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise PluginError(f"Plugin '{eintrag.name}' kann nicht geladen werden.")
|
||||
modul = importlib.util.module_from_spec(spec)
|
||||
sys.modules[modulname] = modul
|
||||
spec.loader.exec_module(modul)
|
||||
if not hasattr(modul, "plugin"):
|
||||
raise PluginError(
|
||||
f"Plugin '{eintrag.name}' definiert kein Modulattribut 'plugin'."
|
||||
)
|
||||
plugins.append(_als_plugin(modul.plugin))
|
||||
return plugins
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
"""Verwaltet geladene Plugins; Plugin-Namen sind eindeutig."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._plugins: dict[str, BasePlugin] = {}
|
||||
|
||||
def register(self, plugin: BasePlugin) -> None:
|
||||
if plugin.name in self._plugins:
|
||||
raise PluginError(
|
||||
f"Doppeltes Plugin '{plugin.name}' — Plugin-Namen müssen eindeutig sein."
|
||||
)
|
||||
self._plugins[plugin.name] = plugin
|
||||
|
||||
def all(self) -> list[BasePlugin]:
|
||||
return list(self._plugins.values())
|
||||
|
||||
def get(self, name: str) -> BasePlugin | None:
|
||||
return self._plugins.get(name)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._plugins)
|
||||
|
||||
|
||||
def discover_plugins(
|
||||
plugins_dir: Path | None, entry_point_group: str = ENTRY_POINT_GROUP
|
||||
) -> PluginRegistry:
|
||||
"""Lädt Plugins aus Entry-Points und dem lokalen Verzeichnis."""
|
||||
registry = PluginRegistry()
|
||||
for plugin in _aus_entry_points(entry_point_group):
|
||||
registry.register(plugin)
|
||||
if plugins_dir is not None:
|
||||
for plugin in _aus_verzeichnis(plugins_dir):
|
||||
registry.register(plugin)
|
||||
return registry
|
||||
11
src/redaktionskern/templates/403.html
Normal file
11
src/redaktionskern/templates/403.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titel %}Kein Zugriff — {{ app_name }}{% endblock %}
|
||||
{% block inhalt %}
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-8 max-w-lg">
|
||||
<h1 class="text-xl font-bold text-red-700 mb-2">403 — Kein Zugriff</h1>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Ihre Rolle{% if user %} ({{ rollen_anzeige[user.role] or user.role }}){% endif %} erlaubt den Zugriff auf diese Seite nicht.
|
||||
</p>
|
||||
<a href="/" class="text-emerald-700 font-medium">← Zurück zur Übersicht</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
48
src/redaktionskern/templates/base.html
Normal file
48
src/redaktionskern/templates/base.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block titel %}{{ app_name }}{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-slate-100 text-slate-800 min-h-screen flex flex-col">
|
||||
<header class="bg-white border-b border-slate-200">
|
||||
<nav class="max-w-6xl mx-auto px-4 py-3 flex items-center gap-5" aria-label="Hauptnavigation">
|
||||
<a href="/" class="font-bold text-emerald-700 whitespace-nowrap">🎳 {{ app_name }}</a>
|
||||
{% for eintrag in plugin_nav %}
|
||||
<a href="{{ eintrag.url }}" class="text-sm text-slate-600 hover:text-emerald-700">{{ eintrag.label }}</a>
|
||||
{% endfor %}
|
||||
<div class="ml-auto relative" x-data="{ offen: false }">
|
||||
{% if user %}
|
||||
<button type="button" @click="offen = !offen"
|
||||
class="text-sm font-medium px-2 py-1 rounded hover:bg-slate-100">
|
||||
{{ user.display_name }} ▾
|
||||
</button>
|
||||
<div x-show="offen" x-cloak @click.outside="offen = false"
|
||||
class="absolute right-0 mt-2 w-56 bg-white border border-slate-200 rounded-lg shadow-lg p-3 text-sm z-10">
|
||||
<p class="text-slate-500 mb-2">Angemeldet als<br><span class="font-medium text-slate-800">{{ user.username }}</span></p>
|
||||
<p class="text-slate-500 mb-3">Rolle: <span class="font-medium text-slate-800">{{ rollen_anzeige[user.role] or user.role }}</span></p>
|
||||
{% if user.role == 'admin' %}
|
||||
<a href="/admin/benutzer" class="block px-2 py-1 rounded hover:bg-slate-100 mb-1">Benutzerverwaltung</a>
|
||||
{% endif %}
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="w-full text-left px-2 py-1 rounded hover:bg-red-50 text-red-600">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="/login" class="text-sm font-medium text-emerald-700">Anmelden</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-6">
|
||||
{% block inhalt %}{% endblock %}
|
||||
</main>
|
||||
<footer class="text-center text-xs text-slate-400 py-4">
|
||||
{{ app_name }} · Plugin-Anzahl: {{ geladene_plugins|length }}
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
70
src/redaktionskern/templates/benutzer/formular.html
Normal file
70
src/redaktionskern/templates/benutzer/formular.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titel %}{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer bearbeiten{% endif %} — {{ app_name }}{% endblock %}
|
||||
{% block inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-4">
|
||||
{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer „{{ ziel.username }}“ bearbeiten{% endif %}
|
||||
</h1>
|
||||
|
||||
{% if fehler %}
|
||||
<div class="mb-4 max-w-lg rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert">
|
||||
{{ fehler }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post"
|
||||
action="{% if aktion == 'neu' %}/admin/benutzer/neu{% else %}/admin/benutzer/{{ ziel.id }}{% endif %}"
|
||||
class="bg-white border border-slate-200 rounded-xl p-6 max-w-lg space-y-4">
|
||||
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium mb-1">Benutzername</label>
|
||||
<input id="username" name="username" value="{{ form.username }}" required minlength="3"
|
||||
{% if aktion != 'neu' %}disabled{% endif %}
|
||||
class="w-full border border-slate-300 rounded-lg px-3 py-2 {% if aktion != 'neu' %}bg-slate-100{% endif %}">
|
||||
{% if aktion != 'neu' %}
|
||||
<p class="text-xs text-slate-400 mt-1">Der Benutzername kann nicht geändert werden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="display_name" class="block text-sm font-medium mb-1">Anzeigename</label>
|
||||
<input id="display_name" name="display_name" value="{{ form.display_name }}"
|
||||
class="w-full border border-slate-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="role" class="block text-sm font-medium mb-1">Rolle</label>
|
||||
<select id="role" name="role"
|
||||
{% if ziel and ziel.id == user.id %}disabled title="Eigene Rolle kann nicht geändert werden"{% endif %}
|
||||
class="w-full border border-slate-300 rounded-lg px-3 py-2 bg-white">
|
||||
{% for rollen_wert, rollen_text in rollen_anzeige.items() %}
|
||||
<option value="{{ rollen_wert }}" {% if form.role == rollen_wert %}selected{% endif %}>{{ rollen_text }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if aktion == 'bearbeiten' %}
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="active" {% if ziel.active %}checked{% endif %}
|
||||
{% if ziel and ziel.id == user.id %}disabled{% endif %}>
|
||||
Aktiv
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium mb-1">
|
||||
{% if aktion == 'neu' %}Passwort{% else %}Neues Passwort (leer = unverändert){% endif %}
|
||||
</label>
|
||||
<input id="password" name="password" type="password" {% if aktion == 'neu' %}required{% endif %} minlength="8"
|
||||
class="w-full border border-slate-300 rounded-lg px-3 py-2">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button type="submit"
|
||||
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg px-4 py-2">
|
||||
Speichern
|
||||
</button>
|
||||
<a href="/admin/benutzer"
|
||||
class="border border-slate-300 rounded-lg px-4 py-2 text-sm hover:bg-slate-50">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
46
src/redaktionskern/templates/benutzer/liste.html
Normal file
46
src/redaktionskern/templates/benutzer/liste.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titel %}Benutzerverwaltung — {{ app_name }}{% endblock %}
|
||||
{% block inhalt %}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Benutzerverwaltung</h1>
|
||||
<a href="/admin/benutzer/neu"
|
||||
class="bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg px-3 py-2 text-sm font-medium">
|
||||
+ Neuer Benutzer
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl border border-slate-200 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-2 font-medium">Benutzername</th>
|
||||
<th class="px-4 py-2 font-medium">Anzeigename</th>
|
||||
<th class="px-4 py-2 font-medium">Rolle</th>
|
||||
<th class="px-4 py-2 font-medium">Status</th>
|
||||
<th class="px-4 py-2 font-medium">Erstellt am</th>
|
||||
<th class="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for b in benutzer %}
|
||||
<tr>
|
||||
<td class="px-4 py-2 font-medium">{{ b.username }}</td>
|
||||
<td class="px-4 py-2">{{ b.display_name }}</td>
|
||||
<td class="px-4 py-2">{{ rollen_anzeige[b.role] or b.role }}</td>
|
||||
<td class="px-4 py-2">
|
||||
{% if b.active %}
|
||||
<span class="text-emerald-700">aktiv</span>
|
||||
{% else %}
|
||||
<span class="text-red-600">deaktiviert</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-slate-500">{{ b.created_at.strftime('%d.%m.%Y') if b.created_at else '–' }}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<a class="text-emerald-700 hover:underline" href="/admin/benutzer/{{ b.id }}">Bearbeiten</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
24
src/redaktionskern/templates/dashboard.html
Normal file
24
src/redaktionskern/templates/dashboard.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titel %}Übersicht — {{ app_name }}{% endblock %}
|
||||
{% block inhalt %}
|
||||
<h1 class="text-2xl font-bold 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">
|
||||
{% 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>
|
||||
<span class="text-xs text-slate-400">v{{ plugin.version }}</span>
|
||||
<span class="text-sm text-slate-500">{{ plugin.description }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
36
src/redaktionskern/templates/login.html
Normal file
36
src/redaktionskern/templates/login.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Anmelden — {{ app_name }}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-slate-100 text-slate-800 min-h-screen flex items-center justify-center px-4">
|
||||
<div class="bg-white shadow-md rounded-xl p-8 w-full max-w-sm">
|
||||
<h1 class="text-xl font-bold mb-1">🎳 {{ app_name }}</h1>
|
||||
<p class="text-sm text-slate-500 mb-6">Bitte anmelden.</p>
|
||||
{% if fehler %}
|
||||
<div class="mb-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert">
|
||||
{{ fehler }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/login" class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium mb-1">Benutzername</label>
|
||||
<input id="username" name="username" value="{{ username }}" required autocomplete="username"
|
||||
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium mb-1">Passwort</label>
|
||||
<input id="password" name="password" type="password" required autocomplete="current-password"
|
||||
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg px-3 py-2">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user