CP_EHRENAMT/app/routes/dokumente.py
2026-06-25 18:58:44 +02:00

236 lines
7.6 KiB
Python

"""Dokumente: Upload mit Freigabe-Workflow (CLAUDE.md).
Alle dürfen hochladen; der Upload startet als wartet_auf_freigabe und ist nur
für Admin + Uploader sichtbar. Der Admin gibt frei oder lehnt ab (optional mit
Grund); Admin-Uploads sind direkt freigegeben. Freigegebene Dokumente sehen alle
im Team, getrennt in Aktuell und Archiv (Verschieben macht der Admin).
Dateien liegen außerhalb von static/ (instance/uploads/) und werden nur über die
geschützte Route /dokumente/<id>/datei ausgeliefert. Reine POST-Formulare, kein JS.
Alles team-gescoped (team_query) und CSRF-geschützt.
"""
import os
import uuid
from flask import (
Blueprint,
abort,
current_app,
flash,
redirect,
render_template,
request,
send_from_directory,
url_for,
)
from .. import db
from ..auth import admin_required, current_user, login_required
from ..models import Dokument
from ..team_scope import get_active_team_id, team_query
bp = Blueprint("dokumente", __name__, url_prefix="/dokumente")
# Endungs-Whitelist (CLAUDE.md: PDF/Word/Bild) + Tabellen.
ERLAUBTE_ENDUNGEN = {
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"png",
"jpg",
"jpeg",
"gif",
"webp",
}
def _endung(dateiname):
"""Kleingeschriebene Dateiendung ohne Punkt (oder '' ohne Endung)."""
if not dateiname or "." not in dateiname:
return ""
return dateiname.rsplit(".", 1)[1].lower()
def _darf_sehen(dok, user):
"""Sichtbarkeit eines einzelnen Dokuments für den aktuellen Nutzer."""
if dok.status == Dokument.STATUS_FREIGEGEBEN:
return True
# Wartend oder abgelehnt: nur Admin oder der Uploader.
return user.ist_admin or dok.uploader_id == user.id
# -----------------------------------------------------------------------------
# Liste (alle): freigegeben in Aktuell/Archiv + eigene wartende Uploads
# -----------------------------------------------------------------------------
@bp.route("/")
@login_required
def liste():
user = current_user()
freigegeben = (
team_query(Dokument)
.filter_by(status=Dokument.STATUS_FREIGEGEBEN)
.order_by(Dokument.erstellt_am.desc())
.all()
)
aktuell = [d for d in freigegeben if d.ablage == Dokument.ABLAGE_AKTUELL]
archiv = [d for d in freigegeben if d.ablage == Dokument.ABLAGE_ARCHIV]
# Eigene noch nicht freigegebene Uploads (nur für den Uploader sichtbar).
eigene_wartend = (
team_query(Dokument)
.filter(Dokument.uploader_id == user.id)
.filter(Dokument.status != Dokument.STATUS_FREIGEGEBEN)
.order_by(Dokument.erstellt_am.desc())
.all()
)
offene_freigaben = 0
if user.ist_admin:
offene_freigaben = (
team_query(Dokument).filter_by(status=Dokument.STATUS_WARTET).count()
)
return render_template(
"dokumente.html",
aktuell=aktuell,
archiv=archiv,
eigene_wartend=eigene_wartend,
offene_freigaben=offene_freigaben,
)
# -----------------------------------------------------------------------------
# Upload (alle): Pflicht-Titel + Datei, serverseitige Typ-/Größenprüfung
# -----------------------------------------------------------------------------
@bp.route("/upload", methods=("POST",))
@login_required
def upload():
user = current_user()
titel = (request.form.get("titel") or "").strip()
datei = request.files.get("datei")
if not titel:
flash("Bitte einen Titel angeben.")
return redirect(url_for("dokumente.liste"))
if datei is None or datei.filename == "":
flash("Bitte eine Datei auswählen.")
return redirect(url_for("dokumente.liste"))
endung = _endung(datei.filename)
if endung not in ERLAUBTE_ENDUNGEN:
flash("Dieser Dateityp ist nicht erlaubt. Erlaubt: PDF, Word, Excel, Bilder.")
return redirect(url_for("dokumente.liste"))
# Kollisionsfreier, sicherer Speichername. Der Originalname dient nur als
# Titel-Hilfe; gespeichert wird unter einem zufälligen Namen.
speichername = f"{uuid.uuid4().hex}.{endung}"
datei.save(os.path.join(current_app.config["UPLOAD_FOLDER"], speichername))
# Admin-Uploads sind direkt freigegeben, sonst Warteschlange.
status = Dokument.STATUS_FREIGEGEBEN if user.ist_admin else Dokument.STATUS_WARTET
dok = Dokument(
team_id=get_active_team_id(),
uploader_id=user.id,
titel=titel,
datei_pfad=speichername,
status=status,
)
db.session.add(dok)
db.session.commit()
if user.ist_admin:
flash("Dokument hochgeladen und freigegeben.")
else:
flash("Dokument hochgeladen. Es ist sichtbar, sobald der Admin es freigibt.")
return redirect(url_for("dokumente.liste"))
# -----------------------------------------------------------------------------
# Datei ausliefern (geschützt, mit Sichtbarkeitsprüfung)
# -----------------------------------------------------------------------------
@bp.route("/<int:dok_id>/datei")
@login_required
def datei(dok_id):
dok = team_query(Dokument).filter_by(id=dok_id).first()
if dok is None:
abort(404)
if not _darf_sehen(dok, current_user()):
abort(403)
return send_from_directory(
current_app.config["UPLOAD_FOLDER"],
dok.datei_pfad,
download_name=f"{dok.titel}.{_endung(dok.datei_pfad)}",
)
# -----------------------------------------------------------------------------
# Freigabe-Warteschlange (Admin)
# -----------------------------------------------------------------------------
@bp.route("/freigaben")
@admin_required
def freigaben():
wartend = (
team_query(Dokument)
.filter_by(status=Dokument.STATUS_WARTET)
.order_by(Dokument.erstellt_am.asc())
.all()
)
return render_template("dokumente_freigaben.html", wartend=wartend)
@bp.route("/<int:dok_id>/freigeben", methods=("POST",))
@admin_required
def freigeben(dok_id):
dok = team_query(Dokument).filter_by(id=dok_id).first()
if dok is None:
abort(404)
dok.status = Dokument.STATUS_FREIGEGEBEN
dok.ablehnungsgrund = None
db.session.commit()
flash("Dokument freigegeben.")
return redirect(url_for("dokumente.freigaben"))
@bp.route("/<int:dok_id>/ablehnen", methods=("POST",))
@admin_required
def ablehnen(dok_id):
dok = team_query(Dokument).filter_by(id=dok_id).first()
if dok is None:
abort(404)
dok.status = Dokument.STATUS_ABGELEHNT
dok.ablehnungsgrund = (request.form.get("grund") or "").strip() or None
db.session.commit()
flash("Dokument abgelehnt.")
return redirect(url_for("dokumente.freigaben"))
@bp.route("/<int:dok_id>/verschieben", methods=("POST",))
@admin_required
def verschieben(dok_id):
dok = team_query(Dokument).filter_by(id=dok_id).first()
if dok is None:
abort(404)
if dok.status != Dokument.STATUS_FREIGEGEBEN:
flash("Nur freigegebene Dokumente lassen sich verschieben.")
return redirect(url_for("dokumente.liste"))
dok.ablage = (
Dokument.ABLAGE_ARCHIV
if dok.ablage == Dokument.ABLAGE_AKTUELL
else Dokument.ABLAGE_AKTUELL
)
db.session.commit()
flash("Ins Archiv verschoben." if dok.ablage == Dokument.ABLAGE_ARCHIV else "Zurück zu Aktuell.")
return redirect(url_for("dokumente.liste"))
# -----------------------------------------------------------------------------
# Zu große Datei: freundliche Meldung statt nackter 413-Seite
# -----------------------------------------------------------------------------
@bp.app_errorhandler(413)
def datei_zu_gross(e):
flash("Die Datei ist zu groß (max. 10 MB).")
return redirect(url_for("dokumente.liste"))