Verleih Management System für das Analgenreferat der Stuve
Find a file
2026-07-30 11:46:06 +00:00
assets initialize 2026-07-27 09:23:42 +02:00
migrations initialize 2026-07-27 09:23:42 +02:00
ops change to nginx template 2026-07-30 13:45:21 +02:00
static initialize 2026-07-27 09:23:42 +02:00
templates initialize 2026-07-27 09:23:42 +02:00
tests Einladungstest auf den Wortlaut der Meldung prüfen 2026-07-28 23:02:00 +02:00
vms Vorbelegung in SMTP_SERVER/SMTP_PORT umbenennen 2026-07-28 22:53:57 +02:00
.dockerignore initialize 2026-07-27 09:23:42 +02:00
.gitattributes initialize 2026-07-27 09:23:42 +02:00
.gitignore change to nginx template 2026-07-30 13:45:21 +02:00
pyproject.toml initialize 2026-07-27 09:23:42 +02:00
README.md Merge pull request 'change to nginx template' (#7) from feat/smtp-nur-aus-konto into main 2026-07-30 11:46:06 +00:00
requirements-dev.txt initialize 2026-07-27 09:23:42 +02:00
requirements.txt Mailversand nur noch über die SMTP-Daten des Kontos 2026-07-28 22:48:38 +02:00

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

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 in vms/domain/ und vms/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 (siehe vms/app.py).
  • Lazy DB pro Worker: vms/domain/database.py legt Engine/Session verzögert an; Gunicorn verwirft den Pool nach dem Fork (post_fork in ops/gunicorn.conf.py).
  • Datums-Konvention: Anzeige DD.MM.YYYY, intern ISO — über format_de_date bzw. den Jinja-Filter de_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.sh ruft 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… import und 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 als name: ops in 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_NAME in der .env sticht ihn weiterhin; nötig ist der Eintrag nicht.
  • secrets.enc gehört ins kms_data-Volume (KMS_SECRETS_PATH), nicht ins Container-Dateisystem: Beim Image-Update wird der Container neu erstellt.

Container-Bootstrap (ops/entrypoint.sh)

  1. KMS-Master-Key erzeugen (falls nicht vorhanden)
  2. Flask SECRET_KEY erzeugen und persistieren
  3. Secrets aus den Umgebungsvariablen nach KMS_SECRETS_PATH verschlüsseln (nur, wenn die Datei fehlt)
  4. auf die Datenbank warten, dann Tabellen initialisieren und Spaltenmigrationen fahren
  5. Static-Assets nach /srv/static exportieren — von dort liefert nginx /static/ aus
  6. gunicorn --config ops/gunicorn.conf.py vms.app:app starten

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.

.dockerignore bleibt bewusst im Repo-Root — Docker wertet die Datei relativ zum Build-Kontext aus, nicht relativ zum Dockerfile.

ops/docker-compose.yml ist ein Server-Artefakt: env_file: .env und der nginx-Mount ./nginx-docker.conf.template sind relativ zur Compose-Datei aufgelöst. Für einen lokalen Stack-Test müssen .env und deploy/nginx-docker.conf.template also nach ops/ kopiert werden; beide Kopien sind in .gitignore eingetragen, damit build-push.sh weiterhin 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 .env zurü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, ProxyFix hinter Nginx.
  • Nie eingecheckt: .env, secrets.enc, *.key, kms_local/ (siehe .gitignore).