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:
Flo Hartmann
2026-08-21 18:35:09 +00:00
parent 72f016a263
commit 96a37e878b
50 changed files with 3343 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.git
.venv
data
__pycache__
*.py[cod]
.pytest_cache
*.db

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
data/
*.db
*.sqlite3
.DS_Store

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PYTHONUNBUFFERED=1
# Projektdateien (uv.lock vorhanden -> reproduzierbarer Install)
COPY pyproject.toml uv.lock README.md ./
COPY src ./src
COPY plugins ./plugins
COPY main.py .
RUN uv sync --frozen --no-dev
EXPOSE 8000
VOLUME ["/app/data"]
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

123
README.md Normal file
View File

@@ -0,0 +1,123 @@
# Spiele-Redaktion
**KI-Assistenz für Spielemagazin-Redaktionen** — Multi-User-Webanwendung mit
modularer Plugin-Architektur. Phase 1: lauffähiger Kern mit Plugin-System,
Authentifizierung/Rollen, Migrationen und Plugin-Stubs.
## Stack
- **Backend:** Python 3.11+, FastAPI, SQLAlchemy 2 (SQLite, Postgres-fähig)
- **Frontend:** HTMX + Jinja2 + Tailwind (CDN) + Alpine.js — kein Build-Step
- **Auth:** Session-Cookies (signiert), Passwort-Hashing mit Argon2id
- **Plugins:** Entry-Points (`importlib.metadata`, Gruppe `spiele_redaktion.plugins`)
plus lokales `plugins/`-Verzeichnis
- **Dependencies/Tests:** [uv](https://docs.astral.sh/uv/) + pytest
## Setup
```bash
uv sync # venv + Dependencies
uv run uvicorn main:app --reload # Dev-Server auf http://127.0.0.1:8000
```
Beim ersten Start wird automatisch ein Admin-Konto angelegt:
| Benutzername | Passwort | Rolle |
|--------------|----------|--------|
| `admin` | `admin` | admin |
> ⚠️ Für alles außer der lokalen Entwicklung vor dem Start
> `SPIELE_INITIAL_ADMIN_PASSWORD` setzen und das Passwort nach dem ersten
> Login ändern.
### Umgebungsvariablen
| Variable | Default | Bedeutung |
|----------|---------|-----------|
| `SPIELE_DATABASE_URL` | `sqlite:///./data/spiele-redaktion.db` | SQLAlchemy-URL (Postgres: z. B. `postgresql+psycopg://…`) |
| `SPIELE_SESSION_SECRET` | Dev-Wert | Secret für signierte Session-Cookies |
| `SPIELE_SESSION_DAUER` | `43200` | Session-Dauer in Sekunden (12 h) |
| `SPIELE_INITIAL_ADMIN_PASSWORD` | `admin` | Passwort des initialen Admins |
| `SPIELE_PLUGINS_DIR` | `<Projekt>/plugins` | Pfad zum lokalen Plugin-Verzeichnis |
## Tests
```bash
uv run pytest
```
Abgedeckt: Plugin-Loader lädt alle Stubs (inkl. Lifecycle-Hooks und
Entry-Point-Pfad), Migrations-Laufzeit inkl. Idempotenz, Login/Logout,
Rollen-Zugriff (admin/redakteur vs. rezensent), Benutzerverwaltung.
## Architektur
```
src/redaktionskern/ schlanker Kern — KEINE Fachlogik
├── app.py App-Fabrik: Discovery → Migrations → on_load → Routen
├── config.py Einstellungen (Umgebungsvariablen)
├── db.py Engine/Session (SQLite ↔ Postgres portabel)
├── migrationen.py Migrations-Laufzeit (Tabelle schema_migrations pro Plugin)
├── plugin_loader.py Entry-Points + plugins/-Verzeichnis, eindeutige Namen
├── contracts.py Plugin-Vertrag (BasePlugin, Migration, NavEntry, PluginContext)
└── auth/ Login/Logout, Rollen, Benutzerverwaltung
plugins/ ein Ordner pro Funktion (Phase-1: Stubs, ladbar)
├── neuheiten/ dedup/ planung/ archiv/
├── erinnerung/ benachrichtigung/ audit-log/ export/
└── … je __init__.py + templates/<name>/index.html
```
### Der Plugin-Vertrag
Ein Plugin ist ein Paketordner unter `plugins/` mit einem `__init__.py`, das
ein Modulattribut `plugin` (Instanz einer `BasePlugin`-Unterklasse) bereitstellt.
Alternativ: installiertes Paket mit Entry-Point in der Gruppe
`spiele_redaktion.plugins`.
```python
from fastapi import Depends, Request
from redaktionskern.auth.deps import require_user
from redaktionskern.contracts import BasePlugin, Migration, NavEntry
class MeinPlugin(BasePlugin):
name = "meinplugin" # eindeutig, = Ordnername
title = "Mein Plugin" # deutscher Anzeigename
description = "Was es tut."
def migrations(self): # eigene Schema-Migrationen (optional)
return []
def navigation(self): # Einträge in der Hauptnavigation (optional)
return [NavEntry(label=self.title, url="/meinplugin")]
def on_load(self, context): # Lifecycle-Hook beim Start
super().on_load(context) # context.engine/.session_factory/.templates/.settings
def on_unload(self): # Lifecycle-Hook beim Herunterfahren
super().on_unload()
plugin = MeinPlugin()
```
Der Kern stellt jedem Plugin über `context.templates` eine gemeinsame Jinja-
Umgebung bereit (Plugin-Templates erben `base.html`), dazu Engine und
Session-Fabrik. Migrationen laufen transaktional und werden pro Plugin in
`schema_migrations` protokolliert; die `up`-Funktionen bekommen eine
SQLAlchemy-Connection und können bei Dialekt-Unterschieden verzweigen.
## Deployment (später)
Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware).
Für Postgres genügt `SPIELE_DATABASE_URL`; die Migrationen sind portabel
geschrieben.
## Offene Punkte (nicht Teil von Phase 1)
- CSRF-Schutz für Formulare (aktuell SameSite=Lax-Cookie als Basisschutz)
- Fachliche Plugins (Neuheiten/BGG-Sync, Dedup, Planung, Archiv, Erinnerung,
Benachrichtigung mit Adapter-Muster, Audit-Log, CSV/PDF-Export)
- Hintergrund-Jobs (APScheduler) für BGG-Sync und Erinnerungen
- WeasyPrint für PDF-Export (Systemabhängigkeiten im Container)
- Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets
- Docker-/Podman-Compose-Setup

24
docker-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
app:
build: .
container_name: spiele-redaktion
restart: unless-stopped
environment:
- SPIELE_DATABASE_URL=sqlite:///./data/spiele-redaktion.db
- SPIELE_SESSION_SECRET=${SPIELE_SESSION_SECRET:-change-me-in-production}
# Vor dem ersten Start setzen, sonst wird der Admin mit "admin" angelegt:
- SPIELE_INITIAL_ADMIN_PASSWORD=${SPIELE_INITIAL_ADMIN_PASSWORD:-admin}
volumes:
- ./data:/app/data
networks:
- traefik_default
labels:
- "traefik.enable=true"
- "traefik.http.routers.spiele-redaktion.rule=Host(`swen.henry.insight-it.de`)"
- "traefik.http.routers.spiele-redaktion.entrypoints=websecure"
- "traefik.http.routers.spiele-redaktion.tls.certresolver=le"
- "traefik.http.services.spiele-redaktion.loadbalancer.server.port=8000"
networks:
traefik_default:
external: true

4
main.py Normal file
View File

@@ -0,0 +1,4 @@
"""Startpunkt für den Dev-Server: uv run uvicorn main:app --reload"""
from redaktionskern.app import create_app
app = create_app()

5
plugins/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Lokale Plugins der Spiele-Redaktion.
Jeder Unterordner ist ein Plugin gemäß redaktionskern.contracts.BasePlugin.
Der Loader im Kern findet diese Ordner automatisch.
"""

View File

@@ -0,0 +1,41 @@
"""Plugin „archiv“ — Platzhalter gemäß Plugin-Vertrag.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
"""
from __future__ import annotations
from fastapi import Depends, Request
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
class ArchivPlugin(BasePlugin):
name = "archiv"
title = "Archiv"
description = "Automatische Archivierung von Titeln älter als 12 Monate (Platzhalter)."
def __init__(self) -> None:
super().__init__()
@self.router.get("/archiv")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
return self.context.templates.TemplateResponse(
request=request,
name="archiv/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/archiv")]
plugin = ArchivPlugin()

View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
</p>
{% endblock %}

View File

@@ -0,0 +1,41 @@
"""Plugin „audit-log“ — Platzhalter gemäß Plugin-Vertrag.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
"""
from __future__ import annotations
from fastapi import Depends, Request
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
class AuditLogPlugin(BasePlugin):
name = "audit-log"
title = "Audit-Log"
description = "Wer hat was wann verschoben, eingetragen oder geändert (Platzhalter)."
def __init__(self) -> None:
super().__init__()
@self.router.get("/audit-log")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
return self.context.templates.TemplateResponse(
request=request,
name="audit-log/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/audit-log")]
plugin = AuditLogPlugin()

View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
</p>
{% endblock %}

View File

@@ -0,0 +1,41 @@
"""Plugin „benachrichtigung“ — Platzhalter gemäß Plugin-Vertrag.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
"""
from __future__ import annotations
from fastapi import Depends, Request
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
class BenachrichtigungPlugin(BasePlugin):
name = "benachrichtigung"
title = "Benachrichtigungen"
description = "E-Mail, Telegram und In-App per Adapter-Muster (Platzhalter)."
def __init__(self) -> None:
super().__init__()
@self.router.get("/benachrichtigung")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
return self.context.templates.TemplateResponse(
request=request,
name="benachrichtigung/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/benachrichtigung")]
plugin = BenachrichtigungPlugin()

View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
</p>
{% endblock %}

41
plugins/dedup/__init__.py Normal file
View File

@@ -0,0 +1,41 @@
"""Plugin „dedup“ — Platzhalter gemäß Plugin-Vertrag.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
"""
from __future__ import annotations
from fastapi import Depends, Request
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
class DedupPlugin(BasePlugin):
name = "dedup"
title = "Dedup-Prüfung"
description = "Prüfungen bei Import und händischem Eintrag (Platzhalter)."
def __init__(self) -> None:
super().__init__()
@self.router.get("/dedup")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
return self.context.templates.TemplateResponse(
request=request,
name="dedup/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/dedup")]
plugin = DedupPlugin()

View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
</p>
{% endblock %}

View File

@@ -0,0 +1,41 @@
"""Plugin „erinnerung“ — Platzhalter gemäß Plugin-Vertrag.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
"""
from __future__ import annotations
from fastapi import Depends, Request
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
class ErinnerungPlugin(BasePlugin):
name = "erinnerung"
title = "Erinnerungen"
description = "Erinnerungen vor Redaktionsschluss pro Ausgabe (Platzhalter)."
def __init__(self) -> None:
super().__init__()
@self.router.get("/erinnerung")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
return self.context.templates.TemplateResponse(
request=request,
name="erinnerung/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/erinnerung")]
plugin = ErinnerungPlugin()

View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
</p>
{% endblock %}

View File

@@ -0,0 +1,41 @@
"""Plugin „export“ — Platzhalter gemäß Plugin-Vertrag.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
"""
from __future__ import annotations
from fastapi import Depends, Request
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
class ExportPlugin(BasePlugin):
name = "export"
title = "Export"
description = "Listen als CSV und PDF (Platzhalter)."
def __init__(self) -> None:
super().__init__()
@self.router.get("/export")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
return self.context.templates.TemplateResponse(
request=request,
name="export/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/export")]
plugin = ExportPlugin()

View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
</p>
{% endblock %}

View File

@@ -0,0 +1,41 @@
"""Plugin „neuheiten“ — Platzhalter gemäß Plugin-Vertrag.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
"""
from __future__ import annotations
from fastapi import Depends, Request
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
class NeuheitenPlugin(BasePlugin):
name = "neuheiten"
title = "Neuheiten"
description = "KI-gestützte Neuheitenliste aus der BoardGameGeek-API (Platzhalter)."
def __init__(self) -> None:
super().__init__()
@self.router.get("/neuheiten")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
return self.context.templates.TemplateResponse(
request=request,
name="neuheiten/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/neuheiten")]
plugin = NeuheitenPlugin()

View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
</p>
{% endblock %}

View File

@@ -0,0 +1,41 @@
"""Plugin „planung“ — Platzhalter gemäß Plugin-Vertrag.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
"""
from __future__ import annotations
from fastapi import Depends, Request
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
class PlanungPlugin(BasePlugin):
name = "planung"
title = "Planung"
description = "Planungsliste mit Zuordnung an Rezensenten (Platzhalter)."
def __init__(self) -> None:
super().__init__()
@self.router.get("/planung")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
return self.context.templates.TemplateResponse(
request=request,
name="planung/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/planung")]
plugin = PlanungPlugin()

View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
</p>
{% endblock %}

31
pyproject.toml Normal file
View File

@@ -0,0 +1,31 @@
[project]
name = "spiele-redaktion"
version = "0.1.0"
description = "KI-Assistenz für Spielemagazin-Redaktionen — modularer Kern mit Plugin-Architektur"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"sqlalchemy>=2.0",
"jinja2>=3.1",
"python-multipart>=0.0.9",
"itsdangerous>=2.1",
"argon2-cffi>=23.1",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"httpx>=0.27",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/redaktionskern"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View 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
View 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

View File

@@ -0,0 +1 @@
"""Authentifizierung und Autorisierung (Kern-Infrastruktur)."""

View 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

View 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()
)

View 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}

View 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

View 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)

View 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
View 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)

View 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

View 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

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block titel %}Kein Zugriff &mdash; {{ 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 &mdash; 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">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
</div>
{% endblock %}

View 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">&#127923; {{ 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 }} &#9662;
</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 }} &middot; Plugin-Anzahl: {{ geladene_plugins|length }}
</footer>
</body>
</html>

View File

@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block titel %}{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer bearbeiten{% endif %} &mdash; {{ app_name }}{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-4">
{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer &bdquo;{{ ziel.username }}&ldquo; 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&auml;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&auml;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&auml;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 %}

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block titel %}Benutzerverwaltung &mdash; {{ 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 '&ndash;' }}</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 %}

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block titel %}&Uuml;bersicht &mdash; {{ 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 &mdash; 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 %}

View 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 &mdash; {{ 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">&#127923; {{ 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>

0
tests/__init__.py Normal file
View File

72
tests/conftest.py Normal file
View File

@@ -0,0 +1,72 @@
"""Gemeinsame Fixtures: App mit temporaerer SQLite-DB und TestClient."""
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from redaktionskern.app import create_app
from redaktionskern.config import Settings
PROJEKT_WURZEL = Path(__file__).resolve().parents[1]
ADMIN_PASSWORD = "test-admin-123"
@pytest.fixture
def settings(tmp_path) -> Settings:
return Settings(
database_url=f"sqlite:///{tmp_path / 'test.db'}",
session_secret="test-secret",
initial_admin_password=ADMIN_PASSWORD,
plugins_dir=PROJEKT_WURZEL / "plugins",
)
@pytest.fixture
def settings_ohne_plugins(settings) -> Settings:
"""Nur Entry-Point-Plugins, kein plugins/-Verzeichnis."""
return replace(settings, plugins_dir=None)
@pytest.fixture
def app(settings):
return create_app(settings)
@pytest.fixture
def client(app) -> TestClient:
return TestClient(app)
def melde_an(
client: TestClient,
username: str = "admin",
password: str = ADMIN_PASSWORD,
):
"""Meldet einen Benutzer an und prueft die Weiterleitung."""
antwort = client.post(
"/login", data={"username": username, "password": password},
follow_redirects=False,
)
assert antwort.status_code == 303, f"Login fehlgeschlagen fuer {username}"
return antwort
def lege_benutzer_an(app, username: str, role: str, password: str = "test-12345678"):
"""Legt direkt per DB einen Benutzer an (fuer Rollen-Tests)."""
from redaktionskern.auth.models import User
from redaktionskern.auth.security import hash_password
with app.state.session_factory() as db:
db.add(
User(
username=username,
display_name=username.capitalize(),
password_hash=hash_password(password),
role=role,
active=True,
)
)
db.commit()

66
tests/test_auth.py Normal file
View File

@@ -0,0 +1,66 @@
"""Tests: Login/Logout und initialer Admin."""
from __future__ import annotations
from sqlalchemy import select
from redaktionskern.auth.models import User
def test_initialer_admin_wird_angelegt(app):
with app.state.session_factory() as db:
admin = db.scalar(select(User).where(User.username == "admin"))
assert admin is not None
assert admin.role == "admin"
assert admin.active is True
def test_login_seite_erreichbar(client):
antwort = client.get("/login")
assert antwort.status_code == 200
assert "Anmelden" in antwort.text
def test_geschuetzte_seite_leitet_anonym_zum_login(client):
antwort = client.get("/", follow_redirects=False)
assert antwort.status_code == 303
assert antwort.headers["location"] == "/login"
def test_login_falsches_passwort(client):
antwort = client.post(
"/login",
data={"username": "admin", "password": "falsch"},
follow_redirects=False,
)
assert antwort.status_code == 200
assert "falsch" in antwort.text
def test_login_unbekannter_benutzer(client):
antwort = client.post(
"/login",
data={"username": "niemand", "password": "egal-egal-1"},
follow_redirects=False,
)
assert antwort.status_code == 200
assert "falsch" in antwort.text
def test_login_erfolg_und_dashboard(client):
from tests.conftest import melde_an
melde_an(client)
antwort = client.get("/")
assert antwort.status_code == 200
assert "Willkommen" in antwort.text
def test_logout(client):
from tests.conftest import melde_an
melde_an(client)
antwort = client.post("/logout", follow_redirects=False)
assert antwort.status_code == 303
assert antwort.headers["location"] == "/login"
danach = client.get("/", follow_redirects=False)
assert danach.status_code == 303 # wieder anonym

View File

@@ -0,0 +1,137 @@
"""Tests: Benutzerverwaltung des Admins (anlegen, bearbeiten, Schutzzonen)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from sqlalchemy import select
from redaktionskern.auth.models import User
from tests.conftest import melde_an
def zweiter_client(client) -> TestClient:
"""Frische Sitzung (eigene Cookies) auf derselben App."""
return TestClient(client.app)
def _liste_enthält(html: str, username: str) -> bool:
return f">{username}<" in html
def test_admin_legt_benutzer_an(app, client):
melde_an(client)
antwort = client.post(
"/admin/benutzer/neu",
data={
"username": "clara",
"display_name": "Clara Klar",
"role": "rezensent",
"password": "geheim-1234",
},
follow_redirects=False,
)
assert antwort.status_code == 303
liste = client.get("/admin/benutzer").text
assert _liste_enthält(liste, "clara")
# Der neue Benutzer kann sich anmelden.
melde_an(zweiter_client(client), "clara", "geheim-1234")
def test_doppelter_benutzername_wird_abgelehnt(client):
melde_an(client)
daten = {"username": "doppel", "display_name": "", "role": "rezensent",
"password": "geheim-1234"}
erste = client.post("/admin/benutzer/neu", data=daten, follow_redirects=False)
assert erste.status_code == 303
zweite = client.post("/admin/benutzer/neu", data=daten)
assert zweite.status_code == 400
assert "bereits vergeben" in zweite.text
def test_zu_kurzes_passwort_wird_abgelehnt(client):
melde_an(client)
antwort = client.post(
"/admin/benutzer/neu",
data={"username": "kurzer", "display_name": "", "role": "rezensent",
"password": "kurz"},
)
assert antwort.status_code == 400
assert "mindestens 8 Zeichen" in antwort.text
def test_bearbeiten_rolle_status_passwort(app, client):
melde_an(client)
client.post(
"/admin/benutzer/neu",
data={"username": "benno", "display_name": "Benno B.", "role": "rezensent",
"password": "geheim-1234"},
follow_redirects=False,
)
with app.state.session_factory() as db:
benno = db.scalar(select(User).where(User.username == "benno"))
benno_id = benno.id
antwort = client.post(
f"/admin/benutzer/{benno_id}",
data={"display_name": "Benno Bearbeitet", "role": "redakteur",
"active": "on", "password": ""},
follow_redirects=False,
)
assert antwort.status_code == 303
with app.state.session_factory() as db:
benno = db.get(User, benno_id)
assert benno.role == "redakteur"
assert benno.display_name == "Benno Bearbeitet"
assert benno.active is True
def test_admin_kann_sich_selbst_nicht_entmachten(client, app):
"""Eigene Rolle/Status sind geschützt — kein Aussperren möglich."""
with app.state.session_factory() as db:
admin = db.scalar(select(User).where(User.username == "admin"))
admin_id = admin.id
melde_an(client)
antwort = client.post(
f"/admin/benutzer/{admin_id}",
data={"display_name": "Administration", "role": "rezensent", "active": ""},
)
assert antwort.status_code == 400
assert "nicht ändern" in antwort.text
with app.state.session_factory() as db:
admin = db.get(User, admin_id)
assert admin.role == "admin"
assert admin.active is True
def test_neuer_benutzer_kann_sich_anmelden_nach_deaktivierung_nicht_mehr(app, client):
melde_an(client)
client.post(
"/admin/benutzer/neu",
data={"username": "dora", "display_name": "Dora D.", "role": "rezensent",
"password": "geheim-1234"},
follow_redirects=False,
)
with app.state.session_factory() as db:
dora = db.scalar(select(User).where(User.username == "dora"))
dora_id = dora.id
client.post(
f"/admin/benutzer/{dora_id}",
data={"display_name": "Dora D.", "role": "rezensent", "active": "",
"password": ""},
follow_redirects=False,
)
zweiter = zweiter_client(client)
antwort = zweiter.post(
"/login",
data={"username": "dora", "password": "geheim-1234"},
follow_redirects=False,
)
assert antwort.status_code == 200
assert "deaktiviert" in antwort.text

78
tests/test_migrations.py Normal file
View File

@@ -0,0 +1,78 @@
"""Tests: Migrations-Laufzeit (Kern-Tabelle, Idempotenz, Plugin-Migration)."""
from __future__ import annotations
from pathlib import Path
from sqlalchemy import text
from redaktionskern.config import Settings
def test_kern_tabellen_existieren(app):
with app.state.engine.connect() as conn:
zeilen = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table'"))
namen = {zeile[0] for zeile in zeilen}
assert "users" in namen
assert "schema_migrations" in namen
def test_migration_wird_protokolliert(app):
with app.state.engine.connect() as conn:
zeilen = conn.execute(
text("SELECT plugin, version FROM schema_migrations")
).fetchall()
protokoll = {(p, v) for p, v in zeilen}
assert ("core", "0001_benutzer") in protokoll
def test_plugin_migration_idempotent(tmp_path):
"""Ein Plugin mit eigener Migration; zweiter Start darf nichts doppelt tun."""
plugin_ordner = tmp_path / "plugins" / "testmig"
plugin_ordner.mkdir(parents=True)
(plugin_ordner / "__init__.py").write_text(
"""
from sqlalchemy import text
from redaktionskern.contracts import BasePlugin, Migration
def _tabelle(conn):
conn.execute(text(
"CREATE TABLE IF NOT EXISTS testplugin_dings (id INTEGER PRIMARY KEY)"
))
class TestMigPlugin(BasePlugin):
name = "testmig"
title = "Testmigration"
def migrations(self):
return [Migration(version="0001_tabelle", up=_tabelle)]
plugin = TestMigPlugin()
""",
encoding="utf-8",
)
settings = Settings(
database_url=f"sqlite:///{tmp_path / 'mig.db'}",
session_secret="s",
initial_admin_password="x",
plugins_dir=tmp_path / "plugins",
)
from redaktionskern.app import create_app
create_app(settings)
app2 = create_app(settings) # zweiter Lauf: darf nicht fehlschlagen
with app2.state.engine.connect() as conn:
tabellen = {
zeile[0]
for zeile in conn.execute(text("SELECT name FROM sqlite_master WHERE type='table'"))
}
protokoll = conn.execute(
text("SELECT version FROM schema_migrations WHERE plugin='testmig'")
).fetchall()
assert "testplugin_dings" in tabellen
assert [z[0] for z in protokoll] == ["0001_tabelle"]

View File

@@ -0,0 +1,96 @@
"""Tests: Plugin-Loader laedt alle Stubs, Lifecycle-Hooks, Entry-Points."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from redaktionskern.contracts import BasePlugin, NavEntry
from redaktionskern.plugin_loader import PluginError, PluginRegistry
ERWARTETE_STUBS = {
"neuheiten",
"dedup",
"planung",
"archiv",
"erinnerung",
"benachrichtigung",
"audit-log",
"export",
}
def test_alle_stubs_werden_geladen(app):
namen = {p.name for p in app.state.registry.all()}
assert ERWARTETE_STUBS <= namen
def test_on_load_wurde_aufgerufen(app):
for plugin in app.state.registry.all():
if plugin.name in ERWARTETE_STUBS:
assert plugin.loaded, f"Plugin {plugin.name} ist nicht geladen."
assert plugin.context is not None
def test_stubs_haben_navigation(app):
for name in ERWARTETE_STUBS:
plugin = app.state.registry.get(name)
assert len(plugin.navigation()) == 1
def test_plugin_seiten_erreichbar(client):
from tests.conftest import melde_an
melde_an(client)
for name in sorted(ERWARTETE_STUBS):
antwort = client.get(f"/{name}")
assert antwort.status_code == 200, name
assert "ist geladen" in antwort.text
def test_on_unload_beim_herunterfahren(app):
plugin = app.state.registry.get("neuheiten")
with TestClient(app):
assert plugin.loaded
assert not plugin.loaded
def test_doppelte_plugin_namen_abgelehnt():
class A(BasePlugin):
name = "a"
registry = PluginRegistry()
registry.register(A())
with pytest.raises(PluginError):
registry.register(A())
def test_entry_point_plugins_werden_geladen(monkeypatch, settings_ohne_plugins):
"""Entry-Point-Erkennung via importlib.metadata (hier simuliert)."""
import redaktionskern.plugin_loader as loader
externes_plugin = loader._als_plugin(ExternesPlugin())
class FakeEntryPoint:
name = "extern"
def load(self):
return externes_plugin
monkeypatch.setattr(loader, "entry_points", lambda group: [FakeEntryPoint()])
from redaktionskern.app import create_app
app = create_app(settings_ohne_plugins)
geladen = app.state.registry.get("extern")
assert geladen is externes_plugin
assert geladen.loaded
class ExternesPlugin(BasePlugin):
"""Simuliert ein als Paket installiertes Plugin (Entry-Point)."""
name = "extern"
title = "Externes Plugin"
description = "Test-Plugin via Entry-Point."
def navigation(self) -> list[NavEntry]:
return []

48
tests/test_rollen.py Normal file
View File

@@ -0,0 +1,48 @@
"""Tests: Rollen-Zugriff (admin/redakteur vs. rezensent, anonym)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import lege_benutzer_an, melde_an
def test_rezensent_kein_admin_bereich(app, client):
lege_benutzer_an(app, "rez1", "rezensent")
melde_an(client, "rez1", "test-12345678")
assert client.get("/admin/benutzer").status_code == 403
assert client.get("/").status_code == 200 # Dashboard erlaubt
def test_redakteur_kein_admin_bereich(app, client):
lege_benutzer_an(app, "red1", "redakteur")
melde_an(client, "red1", "test-12345678")
assert client.get("/admin/benutzer").status_code == 403
assert client.get("/").status_code == 200
def test_admin_hat_zugriff_auf_admin_bereich(client):
melde_an(client)
assert client.get("/admin/benutzer").status_code == 200
def test_anonym_wird_vom_admin_bereich_umgeleitet(client):
antwort = client.get("/admin/benutzer", follow_redirects=False)
assert antwort.status_code == 303
assert antwort.headers["location"] == "/login"
def test_deaktivierter_benutzer_wird_rausgeworfen(app, client):
"""Sitzung eines deaktivierten Kontos verliert den Zugriff sofort."""
lege_benutzer_an(app, "kurz", "rezensent")
melde_an(client, "kurz", "test-12345678")
assert client.get("/").status_code == 200
from redaktionskern.auth.models import User
from sqlalchemy import select
with app.state.session_factory() as db:
benutzer = db.scalar(select(User).where(User.username == "kurz"))
benutzer.active = False
db.commit()
assert client.get("/", follow_redirects=False).status_code == 303

1132
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff