"""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//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("//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("//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("//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("//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"))