Farbschema folgt zunächst der Systemeinstellung, lässt sich per Knopf in der Kopfzeile umstellen und wird dann gemerkt. Bei gesperrtem Speicher (privates Fenster) greift wieder die Systemeinstellung. theme.js steht bewusst OHNE defer und vor dem Inhalt: es setzt die Farbklasse, bevor gezeichnet wird. Mit defer blitzte beim Laden kurz die helle Fassung auf. Eine eigene Datei statt eines <script>-Blocks, weil die CSP nur script-src 'self' erlaubt - inline wäre blockiert. Fusszeile zeigt Version oder Commit, gleiches Muster wie die Kantone-App: die CI reicht beides als Build-Argument durch, lokal erscheint der Build-Zeitpunkt. Der ändert sich bei jedem Neubau und zeigt damit sofort, ob wirklich der neue Stand läuft. Beim Ergänzen der Dunkel-Varianten hatte mein Ersetzer die eben eingefügten dark:-Klassen selbst nochmal getroffen und daraus Paare wie "dark:text-slate-300 dark:text-slate-600" gemacht - bereinigt. 81 Tests, alle grün. Neu darunter: die Fusszeile zeigt Tag bzw. Commit, theme.js steht vor <body> und ohne defer, der Umschalter ist da. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
452 lines
16 KiB
Python
452 lines
16 KiB
Python
"""Die sichtbaren Seiten.
|
|
|
|
Gerendert mit Jinja2, HTMX tauscht beim Filtern nur die Liste aus. Alle
|
|
Formulare tragen ein CSRF-Token: SameSite=Lax schützt zwar schon gegen
|
|
seitenfremde POSTs, aber bei Formularen ist die zusätzliche Prüfung billig
|
|
und fängt auch ältere Browser ab.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import crud, images, security
|
|
from app.config import einstellungen
|
|
from app.database import get_db
|
|
from app.models import Item, Status
|
|
from app.schemas import ItemAendern, ItemAnlegen
|
|
from app.version import version_info
|
|
|
|
router = APIRouter(include_in_schema=False)
|
|
|
|
vorlagen = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
|
|
|
SEITE = 24 # Kacheln pro Nachladen
|
|
|
|
|
|
# --------------------------------------------------------------- Anzeige ---
|
|
|
|
GESCHLECHT_TEXT = {"girl": "Mädchen", "boy": "Buben", "unisex": "egal"}
|
|
ZUSTAND_TEXT = {"new": "neu", "very_good": "sehr gut", "good": "gut", "worn": "getragen"}
|
|
SAISON_TEXT = {
|
|
"spring_summer": "Frühling/Sommer",
|
|
"autumn_winter": "Herbst/Winter",
|
|
"all_year": "ganzjährig",
|
|
}
|
|
|
|
|
|
def _groesse_text(item: Item) -> str:
|
|
teile = [item.size] if item.size else []
|
|
if item.kategorie:
|
|
teile.append(item.kategorie.name)
|
|
return " · ".join(teile) or "ohne Angabe"
|
|
|
|
|
|
def _umgebung(request: Request, db: Session, **extra) -> dict:
|
|
"""Gemeinsame Werte für jede Seite."""
|
|
betreiber = security.ist_betreiber(request)
|
|
entwurf_anzahl = 0
|
|
if betreiber:
|
|
_, entwurf_anzahl = crud.items_suchen(db, status=Status.draft.value, limit=1)
|
|
|
|
return {
|
|
"request": request,
|
|
"betreiber": betreiber,
|
|
"entwurf_anzahl": entwurf_anzahl,
|
|
"csrf": security.csrf_token(request),
|
|
"geschlecht_text": lambda w: GESCHLECHT_TEXT.get(w, w),
|
|
"zustand_text": lambda w: ZUSTAND_TEXT.get(w, w),
|
|
"saison_text": lambda w: SAISON_TEXT.get(w, w),
|
|
"groesse_text": _groesse_text,
|
|
"version": version_info(),
|
|
"meldung": request.query_params.get("meldung"),
|
|
"meldung_art": request.query_params.get("art", "ok"),
|
|
**extra,
|
|
}
|
|
|
|
|
|
def _csrf_oder_fehler(request: Request, csrf: str) -> None:
|
|
if not security.csrf_pruefen(request, csrf):
|
|
raise HTTPException(
|
|
status_code=400, detail="Das Formular ist abgelaufen. Bitte neu laden."
|
|
)
|
|
|
|
|
|
def _weiter(pfad: str, meldung: str | None = None, art: str = "ok") -> RedirectResponse:
|
|
"""Nach jedem POST eine Weiterleitung (Post/Redirect/Get).
|
|
|
|
Ohne das würde ein Neuladen der Seite das Formular erneut abschicken -
|
|
beim Reservieren wäre das besonders unschön.
|
|
"""
|
|
from urllib.parse import quote
|
|
|
|
if meldung:
|
|
trenner = "&" if "?" in pfad else "?"
|
|
pfad = f"{pfad}{trenner}meldung={quote(meldung)}&art={art}"
|
|
return RedirectResponse(pfad, status_code=303)
|
|
|
|
|
|
# --------------------------------------------------------------- Galerie ---
|
|
|
|
def _liste_daten(db: Session, request: Request, limit: int) -> dict:
|
|
p = request.query_params
|
|
status = p.get("status", Status.available.value) or None
|
|
kat = p.get("category_id") or None
|
|
|
|
items, gesamt = crud.items_suchen(
|
|
db,
|
|
size=p.get("size") or None,
|
|
category_id=int(kat) if kat and kat.isdigit() else None,
|
|
status=status,
|
|
gender=p.get("gender") or None,
|
|
limit=limit,
|
|
offset=0,
|
|
)
|
|
|
|
# Für "Mehr anzeigen": dieselben Filter, nur mehr Zeilen.
|
|
weiter = dict(p)
|
|
weiter["limit"] = str(limit + SEITE)
|
|
mehr = "&".join(f"{k}={v}" for k, v in weiter.items())
|
|
|
|
return {
|
|
"items": items,
|
|
"gesamt": gesamt,
|
|
"mehr_url": f"/teil/liste?{mehr}",
|
|
"filter": {
|
|
"size": p.get("size", ""),
|
|
"category_id": p.get("category_id", ""),
|
|
"gender": p.get("gender", ""),
|
|
"status": p.get("status", Status.available.value),
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
def galerie(request: Request, db: Session = Depends(get_db)):
|
|
limit = int(request.query_params.get("limit") or SEITE)
|
|
return vorlagen.TemplateResponse(
|
|
request, "galerie.html", _umgebung(request, db, kategorien=crud.kategorien(db),
|
|
groessen=crud.groessen(db), **_liste_daten(db, request, limit)),
|
|
)
|
|
|
|
|
|
@router.get("/teil/liste", response_class=HTMLResponse)
|
|
def liste_ausschnitt(request: Request, db: Session = Depends(get_db)):
|
|
"""Nur die Liste - von HTMX beim Filtern und Nachladen geholt."""
|
|
limit = int(request.query_params.get("limit") or SEITE)
|
|
return vorlagen.TemplateResponse(
|
|
request, "_liste.html", _umgebung(request, db, **_liste_daten(db, request, limit))
|
|
)
|
|
|
|
|
|
@router.get("/kleid/{item_id}", response_class=HTMLResponse)
|
|
def detail(item_id: int, request: Request, db: Session = Depends(get_db)):
|
|
item = crud.item_holen(db, item_id)
|
|
if item is None or (item.status == Status.draft.value
|
|
and not security.ist_betreiber(request)):
|
|
raise HTTPException(status_code=404, detail="Nicht gefunden.")
|
|
return vorlagen.TemplateResponse(
|
|
request, "detail.html", _umgebung(request, db, item=item))
|
|
|
|
|
|
# ---------------------------------------------------------- Reservierung ---
|
|
|
|
@router.post("/kleid/{item_id}/reservieren")
|
|
@security.limiter.shared_limit(
|
|
lambda: einstellungen().limit_reservieren, scope="reservieren"
|
|
)
|
|
def reservieren(
|
|
request: Request,
|
|
item_id: int,
|
|
reserved_by: str = Form(...),
|
|
csrf: str = Form(...),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_csrf_oder_fehler(request, csrf)
|
|
item = crud.item_holen(db, item_id)
|
|
if item is None or item.status != Status.available.value:
|
|
return _weiter(f"/kleid/{item_id}", "Das ist leider schon weg.", "fehler")
|
|
|
|
from app.schemas import ReservierenRein
|
|
|
|
try:
|
|
geprueft = ReservierenRein(reserved_by=reserved_by)
|
|
except ValueError:
|
|
return _weiter(f"/kleid/{item_id}", "Bitte nur einen Namen angeben.", "fehler")
|
|
|
|
token = crud.reservieren(db, item, geprueft.reserved_by)
|
|
freigabe = request.url_for("freigeben_per_link", item_id=item.id)
|
|
return vorlagen.TemplateResponse(
|
|
request, "reserviert.html", _umgebung(request, db, item=item, freigabe_url=f"{freigabe}?token={token}"),
|
|
)
|
|
|
|
|
|
@router.get("/kleid/{item_id}/freigeben", name="freigeben_per_link")
|
|
def freigeben_per_link(
|
|
item_id: int, request: Request, token: str = "", db: Session = Depends(get_db)
|
|
):
|
|
"""Selbst-Freigabe über den Link aus der Reservierung."""
|
|
item = crud.item_holen(db, item_id)
|
|
if item is None or item.status != Status.reserved.value:
|
|
return _weiter(f"/kleid/{item_id}", "Da ist nichts reserviert.", "fehler")
|
|
if not crud.token_gueltig(item, token):
|
|
return _weiter(f"/kleid/{item_id}",
|
|
"Dieser Link passt nicht zur Reservierung.", "fehler")
|
|
crud.freigeben(db, item)
|
|
return _weiter(f"/kleid/{item_id}", "Reservierung aufgehoben.")
|
|
|
|
|
|
@router.post("/kleid/{item_id}/freigeben")
|
|
def freigeben_als_betreiber(
|
|
request: Request, item_id: int, csrf: str = Form(...),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
security.betreiber_noetig(request)
|
|
_csrf_oder_fehler(request, csrf)
|
|
item = crud.item_holen(db, item_id)
|
|
if item and item.status == Status.reserved.value:
|
|
crud.freigeben(db, item)
|
|
return _weiter(f"/kleid/{item_id}", "Reservierung aufgehoben.")
|
|
|
|
|
|
@router.post("/kleid/{item_id}/abgegeben")
|
|
def abgegeben(request: Request, item_id: int, csrf: str = Form(...),
|
|
db: Session = Depends(get_db)):
|
|
security.betreiber_noetig(request)
|
|
_csrf_oder_fehler(request, csrf)
|
|
item = crud.item_holen(db, item_id)
|
|
if item:
|
|
crud.als_abgegeben_markieren(db, item)
|
|
return _weiter(f"/kleid/{item_id}", "Als abgegeben vermerkt.")
|
|
|
|
|
|
# -------------------------------------------------------------- Anmeldung ---
|
|
|
|
@router.get("/anmelden", response_class=HTMLResponse)
|
|
def anmelden_seite(request: Request, db: Session = Depends(get_db)):
|
|
return vorlagen.TemplateResponse(
|
|
request, "anmelden.html", _umgebung(request, db))
|
|
|
|
|
|
@router.post("/anmelden")
|
|
@security.limiter.limit(lambda: einstellungen().limit_anmelden)
|
|
def anmelden(request: Request, passwort: str = Form(...), csrf: str = Form(...),
|
|
db: Session = Depends(get_db)):
|
|
_csrf_oder_fehler(request, csrf)
|
|
hash_wert = security.hinterlegter_hash()
|
|
if hash_wert is None:
|
|
return _weiter("/anmelden", "Der Erfassungsbereich ist nicht eingerichtet.", "fehler")
|
|
if not security.passwort_stimmt(passwort, hash_wert):
|
|
return _weiter("/anmelden", "Passwort stimmt nicht.", "fehler")
|
|
security.anmelden(request)
|
|
return _weiter("/erfassen", "Angemeldet.")
|
|
|
|
|
|
@router.post("/abmelden")
|
|
def abmelden(request: Request, csrf: str = Form(...)):
|
|
_csrf_oder_fehler(request, csrf)
|
|
security.abmelden(request)
|
|
return _weiter("/", "Abgemeldet.")
|
|
|
|
|
|
# --------------------------------------------------------------- Erfassen ---
|
|
|
|
def _zuletzt(request: Request) -> dict:
|
|
"""Werte des zuletzt gespeicherten Teils.
|
|
|
|
Beim Abarbeiten einer Kiste sind Grösse, Kategorie und Saison meist
|
|
gleich - das spart pro Stück drei Felder.
|
|
"""
|
|
return request.session.get("zuletzt", {})
|
|
|
|
|
|
def _zuletzt_merken(request: Request, daten: dict) -> None:
|
|
request.session["zuletzt"] = {
|
|
k: daten.get(k) for k in ("size", "category_id", "gender", "season", "condition")
|
|
}
|
|
|
|
|
|
def _formular_umgebung(request: Request, db: Session, **extra) -> dict:
|
|
return _umgebung(
|
|
request, db,
|
|
kategorien=crud.kategorien(db),
|
|
groessen=crud.groessen(db),
|
|
zuletzt=_zuletzt(request),
|
|
platzhalter_titel="wird sonst automatisch gebildet",
|
|
**extra,
|
|
)
|
|
|
|
|
|
@router.get("/erfassen", response_class=HTMLResponse)
|
|
def erfassen_seite(request: Request, db: Session = Depends(get_db)):
|
|
security.betreiber_noetig(request)
|
|
return vorlagen.TemplateResponse(
|
|
request, "erfassen.html", _formular_umgebung(request, db, vorgabe=None)
|
|
)
|
|
|
|
|
|
def _bilder_speichern(dateien: list[UploadFile] | None) -> list[str]:
|
|
cfg = einstellungen()
|
|
namen = []
|
|
for datei in dateien or []:
|
|
if not datei or not datei.filename:
|
|
continue
|
|
rohdaten = datei.file.read(cfg.max_upload_bytes + 1)
|
|
namen.append(images.speichern(rohdaten, cfg.upload_pfad, cfg.max_upload_bytes))
|
|
return namen
|
|
|
|
|
|
@router.post("/erfassen")
|
|
def erfassen(
|
|
request: Request,
|
|
csrf: str = Form(...),
|
|
title: str = Form(""),
|
|
description: str = Form(""),
|
|
size: str = Form(...),
|
|
category_id: str = Form(...),
|
|
gender: str = Form("unisex"),
|
|
season: str = Form("all_year"),
|
|
condition: str = Form("good"),
|
|
dateien: list[UploadFile] = File(default=[]),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
security.betreiber_noetig(request)
|
|
_csrf_oder_fehler(request, csrf)
|
|
|
|
try:
|
|
namen = _bilder_speichern(dateien)
|
|
except images.BildFehler as e:
|
|
return _weiter("/erfassen", str(e), "fehler")
|
|
|
|
daten = ItemAnlegen(
|
|
title=title, description=description, size=size,
|
|
category_id=int(category_id), gender=gender, season=season,
|
|
condition=condition,
|
|
)
|
|
item = crud.item_anlegen(db, daten)
|
|
for name in namen:
|
|
crud.bild_anhaengen(db, item, name)
|
|
|
|
_zuletzt_merken(request, {
|
|
"size": size, "category_id": category_id, "gender": gender,
|
|
"season": season, "condition": condition,
|
|
})
|
|
# Zurück ins Formular statt auf die Detailseite: beim Erfassen einer
|
|
# Kiste will man sofort weitermachen.
|
|
return _weiter("/erfassen", f"„{item.anzeige_titel}“ gespeichert.")
|
|
|
|
|
|
@router.post("/stapel")
|
|
def stapel(request: Request, csrf: str = Form(...),
|
|
dateien: list[UploadFile] = File(...), db: Session = Depends(get_db)):
|
|
security.betreiber_noetig(request)
|
|
_csrf_oder_fehler(request, csrf)
|
|
|
|
try:
|
|
namen = _bilder_speichern(dateien)
|
|
except images.BildFehler as e:
|
|
return _weiter("/erfassen", f"Abgebrochen: {e}", "fehler")
|
|
|
|
if not namen:
|
|
return _weiter("/erfassen", "Keine Fotos ausgewählt.", "fehler")
|
|
|
|
entwuerfe = crud.entwuerfe_anlegen(db, len(namen))
|
|
for entwurf, name in zip(entwuerfe, namen):
|
|
crud.bild_anhaengen(db, entwurf, name)
|
|
|
|
return _weiter("/entwuerfe", f"{len(namen)} Fotos hochgeladen.")
|
|
|
|
|
|
@router.get("/entwuerfe", response_class=HTMLResponse)
|
|
def entwuerfe(request: Request, db: Session = Depends(get_db)):
|
|
security.betreiber_noetig(request)
|
|
items, gesamt = crud.items_suchen(db, status=Status.draft.value, limit=100)
|
|
return vorlagen.TemplateResponse(
|
|
request, "entwuerfe.html", _umgebung(request, db, items=items, gesamt=gesamt)
|
|
)
|
|
|
|
|
|
@router.get("/kleid/{item_id}/bearbeiten", response_class=HTMLResponse)
|
|
def bearbeiten_seite(item_id: int, request: Request, db: Session = Depends(get_db)):
|
|
security.betreiber_noetig(request)
|
|
item = crud.item_holen(db, item_id)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="Nicht gefunden.")
|
|
return vorlagen.TemplateResponse(
|
|
request, "bearbeiten.html", _formular_umgebung(request, db, item=item, vorgabe=item)
|
|
)
|
|
|
|
|
|
@router.post("/kleid/{item_id}/bearbeiten")
|
|
def bearbeiten(
|
|
request: Request,
|
|
item_id: int,
|
|
csrf: str = Form(...),
|
|
title: str = Form(""),
|
|
description: str = Form(""),
|
|
size: str = Form(...),
|
|
category_id: str = Form(...),
|
|
gender: str = Form("unisex"),
|
|
season: str = Form("all_year"),
|
|
condition: str = Form("good"),
|
|
dateien: list[UploadFile] = File(default=[]),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
security.betreiber_noetig(request)
|
|
_csrf_oder_fehler(request, csrf)
|
|
|
|
item = crud.item_holen(db, item_id)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="Nicht gefunden.")
|
|
|
|
try:
|
|
namen = _bilder_speichern(dateien)
|
|
except images.BildFehler as e:
|
|
return _weiter(f"/kleid/{item_id}/bearbeiten", str(e), "fehler")
|
|
|
|
war_entwurf = item.status == Status.draft.value
|
|
daten = ItemAendern(
|
|
title=title, description=description, size=size,
|
|
category_id=int(category_id), gender=gender, season=season,
|
|
condition=condition,
|
|
# Ein Entwurf wird durchs Nachtragen sichtbar.
|
|
status=Status.available if war_entwurf else None,
|
|
)
|
|
try:
|
|
crud.item_aendern(db, item, daten)
|
|
except IntegrityError:
|
|
db.rollback()
|
|
return _weiter(f"/kleid/{item_id}/bearbeiten",
|
|
"Grösse und Kategorie werden gebraucht.", "fehler")
|
|
|
|
for name in namen:
|
|
crud.bild_anhaengen(db, item, name)
|
|
|
|
if war_entwurf:
|
|
_zuletzt_merken(request, {
|
|
"size": size, "category_id": category_id, "gender": gender,
|
|
"season": season, "condition": condition,
|
|
})
|
|
# Direkt zum nächsten Entwurf - das ist der eigentliche Arbeitsablauf.
|
|
return _weiter("/entwuerfe", "Veröffentlicht.")
|
|
return _weiter(f"/kleid/{item_id}", "Gespeichert.")
|
|
|
|
|
|
@router.post("/kleid/{item_id}/loeschen")
|
|
def loeschen(request: Request, item_id: int, csrf: str = Form(...),
|
|
db: Session = Depends(get_db)):
|
|
security.betreiber_noetig(request)
|
|
_csrf_oder_fehler(request, csrf)
|
|
item = crud.item_holen(db, item_id)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="Nicht gefunden.")
|
|
for name in crud.item_loeschen(db, item):
|
|
images.loeschen(einstellungen().upload_pfad, name)
|
|
return _weiter("/", "Gelöscht.")
|