|
|
||
|---|---|---|
| assets | ||
| migrations | ||
| ops | ||
| static | ||
| templates | ||
| tests | ||
| vms | ||
| .dockerignore | ||
| .gitattributes | ||
| .gitignore | ||
| pyproject.toml | ||
| README.md | ||
| requirements-dev.txt | ||
| requirements.txt | ||
VMS — Verleih-Management-System
Flask-Anwendung zur Verwaltung von Materialverleih: erzeugt Leihverträge, Rechnungen und Umbuchungen aus versionierten ODT-Vorlagen (Export als PDF), verwaltet Inventar, importiert Leihanfragen per E-Mail und hält den Status mit einem Kanboard-Board synchron. Mit Nutzer-Authentifizierung, verschlüsselter Secret-Verwaltung (KMS) und PostgreSQL-Backend.
Inhalt
- Funktionsumfang
- Technologie-Stack
- Projektstruktur
- Architektur-Prinzipien
- Lokale Entwicklung
- Konfiguration (Umgebungsvariablen)
- Datenbank & Migrationen
- Tests
- Deployment
- Sicherheit
Funktionsumfang
| Bereich | Beschreibung |
|---|---|
| Dokumente | Leihvertrag, Rechnung und Umbuchung aus ODT-Vorlagen füllen und als PDF exportieren (LibreOffice im Headless-Modus). Vorlagen werden versioniert im template_store gehalten. |
| Inventar | CRUD für Artikel, Bundles und Lagerorte; Tags für die Materialauswahl. |
| E-Mail-Import | Leihanfragen werden per IMAP abgeholt, das Formular aus dem Mail-Text extrahiert und als EmailCandidate gespeichert. |
| Kanboard-Abgleich | Kandidaten werden mit Karten auf einem Kanboard-Board abgeglichen (Spaltenwechsel je nach Verleih-Status); ein täglicher Scheduler stößt den Abgleich automatisch an. |
| Rechnungen | Fortlaufende, eindeutige Rechnungs-/Umbuchungsnummern (DB-seitig über einen Unique-Index abgesichert). |
| Nutzerverwaltung | Login (bcrypt-Hashes), Einladungs-Flow, Rate-Limiting, CSRF-Schutz. |
Technologie-Stack
- Backend: Python 3.12, Flask 3, SQLAlchemy 2, Flask-Login, Flask-WTF, Flask-Limiter
- Datenbank: PostgreSQL (psycopg2)
- Dokumente: ODT-Vorlagen + LibreOffice (
libreoffice-writer) für die PDF-Konvertierung - Scheduling: APScheduler (täglicher Kanboard-Abgleich, per Postgres-Advisory-Lock gegen Mehrfachausführung geschützt)
- Secrets: eigenes KMS-Modul (Fernet-verschlüsselte
secrets.enc, Master-Key auf der Platte) - Server: Gunicorn hinter Nginx
- Tests: pytest, pytest-cov, pytest-mock, Testcontainers (ephemeres PostgreSQL), responses, freezegun
Projektstruktur
vms/ Anwendungspaket (importierbar als vms.*)
app.py Flask-Wiring, Blueprint-Registrierung, Top-Level-Routes
(/, /health, /api/generate, Kanboard-Proxy, Scheduler-Setup)
auth.py Nutzer, Login, Einladungen, Rate-Limiter (querschnittlich)
routes/ je ein Flask-Blueprint pro Datei
inventory.py Inventar-, Bundle- und Lagerort-CRUD
invoice.py Rechnung / Umbuchung erzeugen & versenden
verleih.py Leihvorgänge / Vertragserstellung
email.py E-Mail-Kandidaten, Sync-Endpunkte
settings.py Nutzer- & Systemeinstellungen
templates.py Upload/Verwaltung der ODT-Dokumentvorlagen
domain/ Kern-Domäne, ohne Web-Abhängigkeit
models.py SQLAlchemy-Modelle, Datums-Helfer, laufende Nummern
database.py Engine-/Session-Verwaltung (lazy, per Worker)
clients/ Anbindung externer Systeme
email_client.py IMAP-Import & Parsing der Leihanfragen
kanboard_client.py Kanboard-JSON-RPC, Status-Reconciliation
infra/ Infrastruktur / Querschnitt
kms.py Laden/Entschlüsseln der Secrets (Fernet)
kms_setup.py CLI zum Provisionieren des Secret-Stores
security.py Fernet-Ver-/Entschlüsselung einzelner Werte
odt_processor.py Platzhalter-Ersetzung in ODT + ODT→PDF-Konvertierung
template_store.py versionierter Store der ODT-Vorlagen
migrations/ einmalige Migrations-Skripte (migrate_*.py)
assets/ gebündelte ODT-Vorlagen (template*.odt) — Fallback/Seed für den Store
templates/ Jinja-HTML-Templates (Frontend)
static/ JS/CSS/Assets fürs Frontend
tests/ pytest-Suite + Fixtures (tests/conftest.py)
ops/ Deployment
Dockerfile Multi-Stage-Image (Build-Kontext = Repo-Root)
build-push.sh Image bauen & in die Registry pushen (Tag = Git-SHA)
docker-compose.yml db + app + nginx + certbot (Server-Artefakt)
gunicorn.conf.py Gunicorn-Config inkl. post_fork-Scheduler
entrypoint.sh Container-Bootstrap (KMS, DB-Init, Migrationen, Start)
deploy/ systemd-Unit (vms.service), nginx-Configs
Architektur-Prinzipien
- Dünne Routes, reine Domäne: Blueprints in
vms/routes/orchestrieren nur; die Fachlogik liegt invms/domain/undvms/infra/. - Absolute Paket-Imports: durchgängig
from vms.<subpkg>.<mod> import …(z. B.from vms.domain.models import EmailCandidate,from vms.infra.template_store import load_template). Keine flachen Imports. - Blueprints + CSRF: API-Blueprints sind vom Formular-CSRF ausgenommen und
nutzen stattdessen den
X-CSRFToken-Header (siehevms/app.py). - Lazy DB pro Worker:
vms/domain/database.pylegt Engine/Session verzögert an; Gunicorn verwirft den Pool nach dem Fork (post_forkinops/gunicorn.conf.py). - Datums-Konvention: Anzeige
DD.MM.YYYY, intern ISO — überformat_de_datebzw. den Jinja-Filterde_date.
Lokale Entwicklung
Voraussetzungen: Python 3.12, ein laufender Docker-Daemon (für die Tests),
sowie libreoffice-writer, falls die PDF-Konvertierung lokal laufen soll.
# 1. Virtuelle Umgebung
python -m venv venv && source venv/bin/activate
# 2. Abhängigkeiten
pip install -r requirements.txt -r requirements-dev.txt
# 3. Konfiguration
cp .env.example .env # falls vorhanden; sonst .env manuell anlegen (siehe unten)
# 4. PostgreSQL bereitstellen (z. B. per Docker)
docker run --rm -e POSTGRES_USER=admin -e POSTGRES_PASSWORD=admin \
-e POSTGRES_DB=vms -p 5432:5432 postgres:16-alpine
# 5. App starten (WSGI-Objekt ist vms.app:app)
gunicorn --config ops/gunicorn.conf.py vms.app:app
# oder für die Entwicklung:
FLASK_APP=vms.app flask run
Die App ist danach unter http://localhost:8000 (Gunicorn) bzw. dem Flask-Dev-Port
erreichbar; /health liefert {"status": "ok"}.
Konfiguration (Umgebungsvariablen)
Ein Wert wird in dieser Reihenfolge gesucht (vms/app.py:42-44): KMS-Store
(secrets.enc, Fernet-verschlüsselt) → Umgebung / .env → eingebauter Default.
Ist kein KMS eingerichtet, läuft die App also allein aus der .env.
.env für den Docker-Betrieb
Vollständige Vorlage — liegt auf dem Server neben der docker-compose.yml,
lokal im Repo-Root. Alle Werte sind mit den tatsächlichen Defaults gefüllt;
ändern musst du nur die mit # ⚠ markierten Zeilen.
# ─── Server ───
# Einzige Stelle, an der die Domain steht: nginx rendert daraus server_name
# und die Zertifikatspfade (ops/deploy/nginx-docker.conf.template).
VMS_DOMAIN=vms.example.org # ⚠ Domain dieses Servers
# ─── PostgreSQL (docker-compose baut daraus DATABASE_URL) ───
POSTGRES_USER=admin
POSTGRES_PASSWORD=bitte-ersetzen # ⚠ langes Zufallswort
POSTGRES_DB=vms
# ─── Flask ───
# SECRET_KEY nicht setzen: der Container erzeugt ihn beim ersten Start und
# legt ihn im kms_data-Volume ab. Eine leere Zeile "SECRET_KEY="
# wäre schlimmer als gar keine — sie ergäbe einen leeren Schlüssel.
FLASK_ENV=production
# ─── Vorbelegung der Einstellungsseite (KEINE Zugangsdaten) ───
# Füllt nur die leeren Formularfelder vor; Benutzername und Passwort trägt
# jeder Nutzer selbst im Konto ein.
IMAP_SERVER=imap.uni-ulm.de
IMAP_PORT=993
SMTP_SERVER=mail.uni-ulm.de
SMTP_PORT=587
KANBOARD_URL=https://todo.stuve.de/jsonrpc.php
KANBOARD_PROJECT_ID=25
# ─── Image & Compose ───
VMS_IMAGE=git.stuve.uni-ulm.de/hru/vms
VMS_TAG=latest # oder ein Git-SHA für Rollback
Es gibt keine SMTP-Zugangsdaten in der .env. E-Mails werden
ausschließlich mit den Zugangsdaten des angemeldeten Nutzers verschickt, die
verschlüsselt in der Datenbank liegen und auf der Einstellungsseite gepflegt
werden (vms/infra/mailer.py). Ist dort nichts hinterlegt, wird nicht
versendet — die Antwort nennt dann die Einstellungen als Abhilfe, statt still
auf einen Systemabsender auszuweichen. Absender ist immer der SMTP-Benutzer des
Kontos.
COMPOSE_PROJECT_NAME gehört nicht hierher — der Projektname steht als
name: ops in der Compose-Datei. Nur wenn ein bestehender Stack andere
Volume-Präfixe als ops_ hat, setzt du ihn hier zusätzlich.
Die folgenden Variablen setzt ops/docker-compose.yml bereits selbst; in der
.env hätten sie keine Wirkung (environment: sticht env_file:):
DATABASE_URL, FLASK_ENV, KMS_MASTER_KEY_PATH, KMS_SECRETS_PATH,
GUNICORN_BIND, GUNICORN_WORKERS, GUNICORN_ACCESS_LOG, GUNICORN_ERROR_LOG.
Zusätzlich ohne Docker (bare metal, vms.service)
DATABASE_URL=postgresql://admin:passwort@localhost:5432/vms
KMS_MASTER_KEY_PATH=/etc/vms/master.key
KMS_SECRETS_PATH=/etc/vms/secrets.enc
GUNICORN_BIND=127.0.0.1:8000
# GUNICORN_WORKERS=2 ohne Angabe: CPU*2+1.
# Nicht leer setzen — int("") lässt Gunicorn beim Start abstürzen.
GUNICORN_ACCESS_LOG=/var/log/vms/access.log
GUNICORN_ERROR_LOG=/var/log/vms/error.log
GUNICORN_LOG_LEVEL=info
SQL_ECHO=false # true loggt jedes SQL-Statement
STATIC_EXPORT_DIR=/srv/static # Ziel des Static-Exports
Bedeutung im Einzelnen
| Variable | Zweck | Default |
|---|---|---|
VMS_DOMAIN |
Domain des Servers; nginx setzt daraus server_name und die Let's-Encrypt-Pfade |
– (Pflicht, Compose bricht sonst ab) |
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB |
Zugang des db-Containers; Compose setzt daraus DATABASE_URL |
– |
DATABASE_URL |
PostgreSQL-Verbindung | postgresql://admin:admin@db:5432/vms |
SECRET_KEY |
Flask-Session-Schlüssel | im Container erzeugt & persistiert |
FLASK_ENV |
production schaltet Secure-Cookies scharf |
– |
IMAP_SERVER / IMAP_PORT |
Vorbelegung der Einstellungsseite | – / 993 |
SMTP_SERVER / SMTP_PORT |
Vorbelegung der SMTP-Felder der Einstellungsseite | – / 587 |
KANBOARD_URL / KANBOARD_PROJECT_ID |
Vorbelegung der Einstellungsseite | – / 25 |
KMS_MASTER_KEY_PATH |
Pfad zum KMS-Master-Key | /etc/vms/master.key |
KMS_SECRETS_PATH |
Pfad zur secrets.enc |
Repo-Root; im Container /etc/vms/secrets.enc |
STATIC_EXPORT_DIR |
Ziel des Static-Exports für Nginx | /srv/static |
SQL_ECHO |
SQLAlchemy-SQL-Logging | false |
GUNICORN_BIND / GUNICORN_WORKERS |
Bind-Adresse / Worker-Zahl | 127.0.0.1:8000 / CPU*2+1 |
GUNICORN_ACCESS_LOG / GUNICORN_ERROR_LOG / GUNICORN_LOG_LEVEL |
Logging | /var/log/vms/* / info |
VMS_IMAGE / VMS_TAG |
Image und Tag für Compose | git.stuve.uni-ulm.de/hru/vms / latest |
Datenbank & Migrationen
-
Das Schema wird beim Bootstrap über
init_db()(vms/domain/database.py) angelegt; der Container-entrypoint.shruft das automatisch auf und führt zusätzlich inline-Spaltenmigrationen aus. -
Einmalige, in sich abgeschlossene Migrationen liegen in
migrations/und werden als Modul ausgeführt, z. B.:python -m migrations.migrate_unique_laufende_nummer(importieren
from vms.domain… importund benötigen daher das Repo-Root auf dem Pfad).
Tests
Die Suite nutzt einen PostgreSQL-Testcontainer — ein laufender Docker-Daemon
ist Voraussetzung. Die Fixtures leben in tests/conftest.py.
pytest # volle Suite mit Coverage (term + xml + html)
pytest -m unit # nur reine Logik, ohne DB/App-Context
pytest -m "not slow" # schnelle Feedback-Schleife
pytest tests/test_app.py # einzelne Datei
Marker (siehe pyproject.toml): unit, integration, route, slow.
Coverage-Reports landen in coverage.xml (maschinell) und htmlcov/ (Review).
pythonpath = ["."] macht das vms-Paket im Testlauf importierbar.
Deployment
Der Server braucht kein Repo-Checkout. Das App-Image wird auf dem
Entwicklungsrechner gebaut und in die Forgejo-Registry gepusht; auf dem Server
liegen nur drei Dateien nebeneinander (z. B. in /opt/vms):
| Datei | Herkunft |
|---|---|
docker-compose.yml |
ops/docker-compose.yml |
nginx-docker.conf.template |
ops/deploy/nginx-docker.conf.template |
.env |
serverspezifisch, nie im Repo |
db, nginx und certbot sind unveränderte Upstream-Images — nur app kommt
aus der eigenen Registry.
Serverspezifisch ist allein die .env. Die beiden anderen Dateien werden
unverändert aus dem Repo kopiert: Die Domain steht als VMS_DOMAIN in der
.env, und das nginx-Image ersetzt sie beim Start in
nginx-docker.conf.template (server_name und beide Zertifikatspfade) nach
/etc/nginx/conf.d/default.conf. NGINX_ENVSUBST_FILTER: "^VMS_" sorgt dafür,
dass dabei nur diese Variable ersetzt wird und nginx-eigene wie $host stehen
bleiben. Eine neue Domain heißt also: VMS_DOMAIN ändern, Zertifikat ziehen,
docker compose up -d nginx.
# Zertifikat für eine neue Domain, bevor nginx sie erwartet
docker compose run --rm --entrypoint certbot certbot certonly --webroot \
-w /var/www/certbot -d "$VMS_DOMAIN" --agree-tos -m <mail> --no-eff-email
Bauen & Ausrollen
# Entwicklungsrechner (einmalig: docker login git.stuve.uni-ulm.de,
# Token-Scope write:package)
./ops/build-push.sh # taggt mit dem aktuellen Git-SHA + latest
# Server
cd /opt/vms
VMS_TAG=<sha> docker compose pull app && VMS_TAG=<sha> docker compose up -d app
Steht VMS_TAG in der .env, genügt docker compose up -d app. Rollback ist
derselbe Befehl mit dem vorherigen SHA. Der Build-Kontext ist das Repo-Root, weil
das Image vms/, assets/, templates/, static/ und migrations/ kopiert.
Das Paket ist öffentlich lesbar — der Server braucht kein docker login.
Zugriffsrechte hängen in Forgejo an der Sichtbarkeit des Paket-Besitzers, nicht
am Repository; das Verknüpfen eines Pakets mit einem Repo ist rein kosmetisch.
Fürs Pushen ist die Anmeldung weiterhin nötig.
Zwei Fallstricke
- Der Projektname bestimmt die Volume-Präfixe (
ops_pgdata,ops_kms_data). Er steht alsname: opsin der Compose-Datei selbst, damit er nicht mehr vom Verzeichnisnamen abhängt — ein anderer Name legt leere Volumes an, die Datenbank wirkt dann leer.COMPOSE_PROJECT_NAMEin der.envsticht ihn weiterhin; nötig ist der Eintrag nicht. secrets.encgehört inskms_data-Volume (KMS_SECRETS_PATH), nicht ins Container-Dateisystem: Beim Image-Update wird der Container neu erstellt.
Container-Bootstrap (ops/entrypoint.sh)
- KMS-Master-Key erzeugen (falls nicht vorhanden)
- Flask
SECRET_KEYerzeugen und persistieren - Secrets aus den Umgebungsvariablen nach
KMS_SECRETS_PATHverschlüsseln (nur, wenn die Datei fehlt) - auf die Datenbank warten, dann Tabellen initialisieren und Spaltenmigrationen fahren
- Static-Assets nach
/srv/staticexportieren — von dort liefert nginx/static/aus gunicorn --config ops/gunicorn.conf.py vms.app:appstarten
Bare-Metal (systemd): ops/deploy/vms.service nach /etc/systemd/system/
kopieren, aktivieren und starten. Der Dienst startet aus dem Checkout-Verzeichnis
gunicorn --config ops/gunicorn.conf.py vms.app:app.
.dockerignorebleibt bewusst im Repo-Root — Docker wertet die Datei relativ zum Build-Kontext aus, nicht relativ zum Dockerfile.
ops/docker-compose.ymlist ein Server-Artefakt:env_file: .envund der nginx-Mount./nginx-docker.conf.templatesind relativ zur Compose-Datei aufgelöst. Für einen lokalen Stack-Test müssen.envunddeploy/nginx-docker.conf.templatealso nachops/kopiert werden; beide Kopien sind in.gitignoreeingetragen, damitbuild-push.shweiterhin einen sauberen Worktree sieht. Der normale Dev-Pfad (venv +flask run, siehe oben) ist davon nicht betroffen.
Sicherheit
- Secret-Management: produktive Secrets liegen Fernet-verschlüsselt in
secrets.enc; der Master-Key liegt getrennt (KMS_MASTER_KEY_PATH). Ohne KMS fällt die App für die Entwicklung auf.envzurück. - Passwörter: bcrypt-Hashes (
vms/auth.py). - Web-Härtung: CSRF-Schutz (Flask-WTF), Rate-Limiting (Flask-Limiter),
Secure-/HttpOnly-/SameSite-Cookies in Produktion,
ProxyFixhinter Nginx. - Nie eingecheckt:
.env,secrets.enc,*.key,kms_local/(siehe.gitignore).