236 lines
7.6 KiB
Python
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"))
|