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:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.venv
|
||||
data
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
.pytest_cache
|
||||
*.db
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
data/
|
||||
*.db
|
||||
*.sqlite3
|
||||
.DS_Store
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal 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
123
README.md
Normal 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
24
docker-compose.yml
Normal 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
4
main.py
Normal 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
5
plugins/__init__.py
Normal 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.
|
||||
"""
|
||||
41
plugins/archiv/__init__.py
Normal file
41
plugins/archiv/__init__.py
Normal 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()
|
||||
10
plugins/archiv/templates/archiv/index.html
Normal file
10
plugins/archiv/templates/archiv/index.html
Normal 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 — die Funktion wird in einer späteren Phase implementiert.
|
||||
</p>
|
||||
{% endblock %}
|
||||
41
plugins/audit-log/__init__.py
Normal file
41
plugins/audit-log/__init__.py
Normal 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()
|
||||
10
plugins/audit-log/templates/audit-log/index.html
Normal file
10
plugins/audit-log/templates/audit-log/index.html
Normal 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 — die Funktion wird in einer späteren Phase implementiert.
|
||||
</p>
|
||||
{% endblock %}
|
||||
41
plugins/benachrichtigung/__init__.py
Normal file
41
plugins/benachrichtigung/__init__.py
Normal 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()
|
||||
@@ -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 — die Funktion wird in einer späteren Phase implementiert.
|
||||
</p>
|
||||
{% endblock %}
|
||||
41
plugins/dedup/__init__.py
Normal file
41
plugins/dedup/__init__.py
Normal 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()
|
||||
10
plugins/dedup/templates/dedup/index.html
Normal file
10
plugins/dedup/templates/dedup/index.html
Normal 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 — die Funktion wird in einer späteren Phase implementiert.
|
||||
</p>
|
||||
{% endblock %}
|
||||
41
plugins/erinnerung/__init__.py
Normal file
41
plugins/erinnerung/__init__.py
Normal 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()
|
||||
10
plugins/erinnerung/templates/erinnerung/index.html
Normal file
10
plugins/erinnerung/templates/erinnerung/index.html
Normal 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 — die Funktion wird in einer späteren Phase implementiert.
|
||||
</p>
|
||||
{% endblock %}
|
||||
41
plugins/export/__init__.py
Normal file
41
plugins/export/__init__.py
Normal 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()
|
||||
10
plugins/export/templates/export/index.html
Normal file
10
plugins/export/templates/export/index.html
Normal 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 — die Funktion wird in einer späteren Phase implementiert.
|
||||
</p>
|
||||
{% endblock %}
|
||||
41
plugins/neuheiten/__init__.py
Normal file
41
plugins/neuheiten/__init__.py
Normal 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()
|
||||
10
plugins/neuheiten/templates/neuheiten/index.html
Normal file
10
plugins/neuheiten/templates/neuheiten/index.html
Normal 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 — die Funktion wird in einer späteren Phase implementiert.
|
||||
</p>
|
||||
{% endblock %}
|
||||
41
plugins/planung/__init__.py
Normal file
41
plugins/planung/__init__.py
Normal 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()
|
||||
10
plugins/planung/templates/planung/index.html
Normal file
10
plugins/planung/templates/planung/index.html
Normal 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 — die Funktion wird in einer späteren Phase implementiert.
|
||||
</p>
|
||||
{% endblock %}
|
||||
31
pyproject.toml
Normal file
31
pyproject.toml
Normal 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"]
|
||||
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>
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
72
tests/conftest.py
Normal file
72
tests/conftest.py
Normal 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
66
tests/test_auth.py
Normal 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
|
||||
137
tests/test_benutzerverwaltung.py
Normal file
137
tests/test_benutzerverwaltung.py
Normal 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
78
tests/test_migrations.py
Normal 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"]
|
||||
96
tests/test_plugin_loader.py
Normal file
96
tests/test_plugin_loader.py
Normal 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
48
tests/test_rollen.py
Normal 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
|
||||
Reference in New Issue
Block a user