Phase 4: Oberfläche mit Galerie, Erfassung und Reservierung

Jinja2 mit HTMX, Tailwind über die Standalone-Binärdatei im Docker-Build -
kein Node.js, kein npm. Mobile-First, Tippflächen mindestens 44 px hoch.

Seiten: Galerie mit Filterleiste, Detailseite, Erfassungsformular,
Stapel-Import mit Entwurfsliste, Anmeldung. HTMX tauscht beim Filtern nur
die Liste aus statt die ganze Seite neu zu laden.

Aus den Entscheidungen im Plan folgt konkret:

- Der Stapel-Import steht ZUERST im Formular: wer zwanzig Teile hat, will
  nicht zwanzigmal ein Formular ausfüllen. Bewusst ohne capture-Attribut,
  damit sich wahlweise fotografieren oder aus der Mediathek wählen lässt -
  mit capture ginge nur je ein Foto pro Vorgang.
- Grösse, Kategorie, Geschlecht, Saison und Zustand werden vom zuletzt
  gespeicherten Teil übernommen. Beim Abarbeiten einer Kiste sind das fünf
  Felder weniger pro Stück.
- Nach dem Nachtragen eines Entwurfs geht es direkt zum nächsten, nicht auf
  die Detailseite - das ist der eigentliche Arbeitsablauf.
- Titel bleibt leer erlaubt; angezeigt wird dann Kategorie und Grösse.
- reserved_by erscheint öffentlich nur als "reserviert". Den Namen sieht
  ausschliesslich der angemeldete Betreiber.
- Nach dem Reservieren wird der Freigabe-Link angezeigt, als vollständige
  Adresse zum Aufbewahren.
- Post/Redirect/Get überall, damit ein Neuladen nicht erneut abschickt.
- CSRF-Token in jedem Formular, zusätzlich zu SameSite=Lax.

HTMX liegt lokal im Repository, nicht vom CDN: die CSP erlaubt nur
script-src 'self', und ein CDN-Aufruf gäbe die IP jedes Besuchers weiter -
dieselbe Überlegung wie bei Swagger und bei den Schriften der Kantone-App.
Ein Test prüft für alle Seiten, dass nichts von aussen geladen wird.

Nebenbei TemplateResponse auf die aktuelle Signatur umgestellt (64
Deprecation-Warnungen weniger).

77 Tests, alle grün. Zusätzlich der ganze Weg gegen den Container:
anmelden, drei Fotos als Stapel, Entwurf nachtragen, Werte werden für das
nächste Teil übernommen, als Gast reservieren, Name bleibt verborgen,
Selbst-Freigabe über den Link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
This commit is contained in:
2026-08-29 23:29:56 +02:00
co-authored by Claude Opus 5
parent 01cd0a73e8
commit 75b95b38e6
18 changed files with 1241 additions and 28 deletions
+18
View File
@@ -1,3 +1,20 @@
# --------------------------------------------------------------- CSS ---
# Tailwind über die Standalone-Binärdatei - kein Node.js, kein npm. Das
# Ergebnis ist eine einzige style.css; die Binärdatei bleibt in dieser
# Stufe und landet nicht im fertigen Image.
FROM debian:bookworm-slim AS css
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
ARG TAILWIND_VERSION=v3.4.17
RUN curl -sSLo /usr/local/bin/tailwindcss \
"https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64" \
&& chmod +x /usr/local/bin/tailwindcss
COPY tailwind.config.js tailwind-input.css ./
COPY app/templates ./app/templates
RUN tailwindcss -c tailwind.config.js -i tailwind-input.css -o /build/style.css --minify
# ----------------------------------------------------------- Anwendung ---
FROM python:3.11-slim
# libheif wird von pillow-heif mitgeliefert; hier nur die Laufzeit-Bibliotheken,
@@ -14,6 +31,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY alembic.ini ./
COPY alembic/ ./alembic/
COPY app/ ./app/
COPY --from=css /build/style.css ./app/static/style.css
COPY docker-entrypoint.sh /usr/local/bin/kleiderboerse-entrypoint.sh
RUN chmod +x /usr/local/bin/kleiderboerse-entrypoint.sh
+2 -28
View File
@@ -19,7 +19,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app.config import einstellungen
from app.database import SessionLocal, engine
from app.routers import auth, categories, images, items
from app.routers import auth, categories, images, items, pages
from app.security import limiter
from app.seed import stammdaten_anlegen
@@ -120,6 +120,7 @@ app.include_router(auth.router)
app.include_router(categories.router)
app.include_router(items.router)
app.include_router(images.router)
app.include_router(pages.router)
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
@@ -144,33 +145,6 @@ def swagger_ui():
)
@app.get("/", include_in_schema=False)
def startseite():
"""Platzhalter, bis die Galerie steht (Phase 4).
Ohne diese Route liefert die Wurzel ein nacktes 404-JSON - im Browser
sieht das aus wie eine kaputte Anwendung.
"""
return HTMLResponse(
"""<!doctype html>
<html lang="de-CH"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Kinderkleider-Börse</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 34rem; margin: 4rem auto;
padding: 0 1rem; line-height: 1.6; color: #1f2937; }
code { background: #f3f4f6; padding: .1rem .35rem; border-radius: .25rem; }
</style></head><body>
<h1>Kinderkleider-Börse</h1>
<p>Die Anwendung läuft. Die Oberfläche entsteht noch bis dahin lässt sich
alles über die <a href="/docs">API-Oberfläche</a> ausprobieren.</p>
<p>Zum Erfassen zuerst <code>POST /api/v1/auth/login</code> ausführen.
Ohne Anmeldung sind Galerie und Reservierung nutzbar.</p>
</body></html>"""
)
@app.get("/robots.txt", include_in_schema=False)
def robots():
return PlainTextResponse("User-agent: *\nDisallow: /\n")
+449
View File
@@ -0,0 +1,449 @@
"""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
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,
"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.")
+1
View File
File diff suppressed because one or more lines are too long
+81
View File
@@ -0,0 +1,81 @@
{# Die Eingabefelder eines Kleidungsstücks. Geteilt zwischen Erfassen und
Bearbeiten, damit beide Formulare nicht auseinanderlaufen.
`vorgabe` ist beim Erfassen leer und beim Bearbeiten das Kleidungsstück;
`zuletzt` sind die Werte des zuletzt gespeicherten Teils - beim
Abarbeiten einer Kiste ändert sich meist nur der Titel. #}
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2">
<label class="etikett" for="title">Titel <span class="font-normal text-slate-400">(freiwillig)</span></label>
<input class="feld" id="title" name="title" maxlength="100"
value="{{ vorgabe.title or '' if vorgabe else '' }}"
placeholder="{{ platzhalter_titel }}">
<p class="text-xs text-slate-500 mt-1">
Leer lassen genügt dann steht dort automatisch Kategorie und Grösse.
</p>
</div>
<div>
<label class="etikett" for="size">Grösse</label>
<select class="feld" id="size" name="size" required>
<option value="">bitte wählen</option>
{% for g in groessen %}
<option value="{{ g.label }}"
{{ 'selected' if (vorgabe.size if vorgabe else zuletzt.size) == g.label }}>
{{ g.label }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="etikett" for="category_id">Kategorie</label>
<select class="feld" id="category_id" name="category_id" required>
<option value="">bitte wählen</option>
{% for k in kategorien %}
<option value="{{ k.id }}"
{{ 'selected' if (vorgabe.category_id if vorgabe else zuletzt.category_id)|string == k.id|string }}>
{{ k.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="etikett" for="gender">Für</label>
<select class="feld" id="gender" name="gender">
{% for wert, text in [('unisex','egal'), ('girl','Mädchen'), ('boy','Buben')] %}
<option value="{{ wert }}"
{{ 'selected' if (vorgabe.gender if vorgabe else zuletzt.gender) == wert }}>
{{ text }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="etikett" for="season">Saison</label>
<select class="feld" id="season" name="season">
{% for wert, text in [('all_year','ganzjährig'), ('spring_summer','Frühling/Sommer'), ('autumn_winter','Herbst/Winter')] %}
<option value="{{ wert }}"
{{ 'selected' if (vorgabe.season if vorgabe else zuletzt.season) == wert }}>
{{ text }}</option>
{% endfor %}
</select>
</div>
<div class="col-span-2">
<label class="etikett" for="condition">Zustand</label>
<select class="feld" id="condition" name="condition">
{% for wert, text in [('good','gut'), ('new','neu'), ('very_good','sehr gut'), ('worn','getragen')] %}
<option value="{{ wert }}"
{{ 'selected' if (vorgabe.condition if vorgabe else zuletzt.condition) == wert }}>
{{ text }}</option>
{% endfor %}
</select>
</div>
<div class="col-span-2">
<label class="etikett" for="description">Bemerkung <span class="font-normal text-slate-400">(freiwillig)</span></label>
<textarea class="feld py-2" id="description" name="description" rows="2"
placeholder="z.B. kleiner Fleck am Ärmel">{{ vorgabe.description or '' if vorgabe else '' }}</textarea>
</div>
</div>
+30
View File
@@ -0,0 +1,30 @@
{# Eine Kachel in der Galerie. Eigene Datei, weil HTMX beim Filtern nur die
Liste austauscht und dieselbe Darstellung braucht. #}
<a href="/kleid/{{ item.id }}" class="karte block group">
<div class="aspect-[3/4] bg-slate-100">
{% if item.hauptbild %}
<img src="/uploads/{{ item.hauptbild.image_url }}"
alt="{{ item.anzeige_titel }}" loading="lazy"
class="w-full h-full object-cover">
{% else %}
<div class="w-full h-full grid place-items-center text-slate-300 text-sm">
kein Bild
</div>
{% endif %}
</div>
<div class="p-3">
<p class="font-semibold leading-tight">{{ item.anzeige_titel }}</p>
<p class="text-sm text-slate-500 mt-1">
{{ groesse_text(item) }}
</p>
{% if item.status == 'reserved' %}
{# Bewusst ohne Namen: die Galerie ist öffentlich, der Name steht
nur in der Verwaltung. #}
<span class="inline-block mt-2 text-xs font-semibold rounded-full
px-2 py-0.5 bg-amber-100 text-amber-900">reserviert</span>
{% elif item.status == 'given_away' %}
<span class="inline-block mt-2 text-xs font-semibold rounded-full
px-2 py-0.5 bg-slate-200 text-slate-600">abgegeben</span>
{% endif %}
</div>
</a>
+25
View File
@@ -0,0 +1,25 @@
{# Nur die Liste - HTMX ersetzt beim Filtern genau diesen Ausschnitt,
statt die ganze Seite neu zu laden. #}
<div id="liste">
{% if items %}
<p class="text-sm text-slate-500 mb-3">{{ gesamt }} Stück</p>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{% for item in items %}{% include "_kachel.html" %}{% endfor %}
</div>
{% if gesamt > items|length %}
<div class="text-center mt-6">
<button class="knopf-still"
hx-get="{{ mehr_url }}"
hx-target="#liste"
hx-swap="outerHTML">
Mehr anzeigen
</button>
</div>
{% endif %}
{% else %}
<p class="text-center text-slate-500 py-12">
Hier ist gerade nichts. Vielleicht mit einem anderen Filter?
</p>
{% endif %}
</div>
+17
View File
@@ -0,0 +1,17 @@
{% extends "basis.html" %}
{% block titel %}Anmelden{% endblock %}
{% block inhalt %}
<form method="post" action="/anmelden" class="max-w-sm mx-auto karte p-6 space-y-4">
<h1 class="text-xl font-bold">Anmelden</h1>
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="etikett" for="pw">Passwort</label>
<input class="feld" id="pw" name="passwort" type="password" required
autocomplete="current-password">
</div>
<button class="knopf-haupt w-full">Anmelden</button>
<p class="text-xs text-slate-500">
Nur zum Erfassen nötig. Stöbern und Reservieren geht ohne.
</p>
</form>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="de-CH">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{% block titel %}Kinderkleider-Börse{% endblock %}</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/style.css">
{# Lokal ausgeliefert, nicht vom CDN: die CSP erlaubt nur script-src
'self', und ein CDN-Aufruf gäbe zudem die IP jedes Besuchers weiter. #}
<script src="/static/htmx.min.js" defer></script>
</head>
<body class="bg-slate-50 text-slate-900 min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200 sticky top-0 z-20">
<div class="max-w-5xl mx-auto px-4 h-14 flex items-center gap-3">
<a href="/" class="font-bold text-marke-700 text-lg">Kleider-Börse</a>
<nav class="ml-auto flex items-center gap-2 text-sm">
{% if betreiber %}
<a href="/erfassen" class="knopf-still">Erfassen</a>
{% if entwurf_anzahl %}
<a href="/entwuerfe" class="knopf-still">
Entwürfe
<span class="bg-marke-700 text-white rounded-full px-2 text-xs">
{{ entwurf_anzahl }}</span>
</a>
{% endif %}
<form method="post" action="/abmelden" class="inline">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="knopf-still">Abmelden</button>
</form>
{% else %}
<a href="/anmelden" class="knopf-still">Anmelden</a>
{% endif %}
</nav>
</div>
</header>
<main class="flex-1 w-full max-w-5xl mx-auto px-4 py-5">
{% if meldung %}
<p class="mb-4 rounded-xl px-4 py-3 text-sm
{{ 'bg-emerald-100 text-emerald-900' if meldung_art == 'ok'
else 'bg-rose-100 text-rose-900' }}">{{ meldung }}</p>
{% endif %}
{% block inhalt %}{% endblock %}
</main>
<footer class="text-center text-xs text-slate-400 py-6">
Kinderkleider-Börse
</footer>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
{% extends "basis.html" %}
{% block titel %}Bearbeiten{% endblock %}
{% block inhalt %}
<h1 class="text-xl font-bold mb-4">
{{ 'Entwurf nachtragen' if item.status == 'draft' else 'Bearbeiten' }}
</h1>
<div class="grid md:grid-cols-2 gap-5">
<div class="karte">
{% if item.bilder %}
<img src="/uploads/{{ item.hauptbild.image_url }}" alt=""
class="w-full object-cover">
{% if item.bilder|length > 1 %}
<div class="grid grid-cols-4 gap-1 p-1">
{% for b in item.bilder %}
<img src="/uploads/{{ b.image_url }}" alt="" loading="lazy"
class="aspect-square object-cover rounded-lg
{{ 'ring-2 ring-marke-600' if b.is_primary }}">
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="aspect-[3/4] grid place-items-center text-slate-300">kein Bild</div>
{% endif %}
</div>
<form method="post" action="/kleid/{{ item.id }}/bearbeiten"
enctype="multipart/form-data" class="karte p-4 space-y-4">
<input type="hidden" name="csrf" value="{{ csrf }}">
{% include "_felder.html" %}
<div>
<label class="etikett" for="fotos">Weitere Fotos</label>
<input class="feld py-2" id="fotos" type="file" name="dateien"
accept="image/*" multiple>
</div>
<button class="knopf-haupt w-full">
{{ 'Veröffentlichen' if item.status == 'draft' else 'Speichern' }}
</button>
{% if item.status != 'draft' %}
<a href="/kleid/{{ item.id }}" class="knopf-still w-full">Abbrechen</a>
{% endif %}
</form>
</div>
{% endblock %}
+102
View File
@@ -0,0 +1,102 @@
{% extends "basis.html" %}
{% block titel %}{{ item.anzeige_titel }}{% endblock %}
{% block inhalt %}
<a href="/" class="text-sm text-marke-700 font-semibold">&larr; Übersicht</a>
<div class="mt-3 grid md:grid-cols-2 gap-5">
<div class="karte">
{% if item.bilder %}
<img src="/uploads/{{ item.hauptbild.image_url }}"
alt="{{ item.anzeige_titel }}" class="w-full object-cover">
{% if item.bilder|length > 1 %}
<div class="grid grid-cols-4 gap-1 p-1">
{% for b in item.bilder %}
<img src="/uploads/{{ b.image_url }}" alt="" loading="lazy"
class="aspect-square object-cover rounded-lg">
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="aspect-[3/4] grid place-items-center text-slate-300">
kein Bild
</div>
{% endif %}
</div>
<div>
<h1 class="text-2xl font-bold">{{ item.anzeige_titel }}</h1>
<dl class="mt-4 grid grid-cols-2 gap-3 text-sm">
<div><dt class="text-slate-500">Grösse</dt>
<dd class="font-semibold">{{ item.size or '' }}</dd></div>
<div><dt class="text-slate-500">Kategorie</dt>
<dd class="font-semibold">{{ item.kategorie.name if item.kategorie else '' }}</dd></div>
<div><dt class="text-slate-500">Für</dt>
<dd class="font-semibold">{{ geschlecht_text(item.gender) }}</dd></div>
<div><dt class="text-slate-500">Zustand</dt>
<dd class="font-semibold">{{ zustand_text(item.condition) }}</dd></div>
<div><dt class="text-slate-500">Saison</dt>
<dd class="font-semibold">{{ saison_text(item.season) }}</dd></div>
</dl>
{% if item.description %}
<p class="mt-4 text-slate-700 whitespace-pre-line">{{ item.description }}</p>
{% endif %}
<div class="mt-6">
{% if item.status == 'available' %}
<form method="post" action="/kleid/{{ item.id }}/reservieren"
class="karte p-4 space-y-3">
<input type="hidden" name="csrf" value="{{ csrf }}">
<label class="etikett" for="wer">Dein Name</label>
<input class="feld" id="wer" name="reserved_by" required
maxlength="100" autocomplete="name"
placeholder="z.B. Familie Meier">
<button class="knopf-haupt w-full">Reservieren</button>
<p class="text-xs text-slate-500">
Du bekommst danach einen Link, mit dem du die Reservierung
selbst wieder aufheben kannst.
</p>
</form>
{% elif item.status == 'reserved' %}
<p class="rounded-xl bg-amber-100 text-amber-900 px-4 py-3">
Ist bereits reserviert.
</p>
{% if betreiber %}
<p class="mt-2 text-sm text-slate-600">
Reserviert von: <strong>{{ item.reserved_by }}</strong>
</p>
<form method="post" action="/kleid/{{ item.id }}/freigeben" class="mt-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="knopf-still">Reservierung aufheben</button>
</form>
{% endif %}
{% elif item.status == 'given_away' %}
<p class="rounded-xl bg-slate-200 text-slate-700 px-4 py-3">
Schon abgegeben.
</p>
{% endif %}
</div>
{% if betreiber %}
<div class="mt-6 border-t border-slate-200 pt-4 flex flex-wrap gap-2">
<a href="/kleid/{{ item.id }}/bearbeiten" class="knopf-still">Bearbeiten</a>
{% if item.status == 'reserved' %}
<form method="post" action="/kleid/{{ item.id }}/abgegeben">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="knopf-still">Als abgegeben markieren</button>
</form>
{% endif %}
<form method="post" action="/kleid/{{ item.id }}/loeschen"
onsubmit="return confirm('Wirklich löschen? Auch die Fotos werden entfernt.')">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="knopf-still text-rose-700">Löschen</button>
</form>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "basis.html" %}
{% block titel %}Entwürfe{% endblock %}
{% block inhalt %}
<h1 class="text-xl font-bold">Unfertige Einträge ({{ gesamt }})</h1>
<p class="text-sm text-slate-600 mt-1 mb-5">
Aus dem Stapel-Import. Erst wenn Grösse und Kategorie eingetragen sind,
erscheint ein Teil in der Galerie.
</p>
{% if items %}
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{% for item in items %}
<a href="/kleid/{{ item.id }}/bearbeiten" class="karte block">
<div class="aspect-[3/4] bg-slate-100">
{% if item.hauptbild %}
<img src="/uploads/{{ item.hauptbild.image_url }}" alt=""
loading="lazy" class="w-full h-full object-cover">
{% endif %}
</div>
<p class="p-3 text-sm font-semibold text-marke-700">Nachtragen &rarr;</p>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-center text-slate-500 py-12">
Nichts offen. <a href="/erfassen" class="text-marke-700 font-semibold">Neue Fotos hochladen</a>
</p>
{% endif %}
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "basis.html" %}
{% block titel %}Erfassen{% endblock %}
{% block inhalt %}
<h1 class="text-xl font-bold mb-4">Kleider erfassen</h1>
{# Der schnelle Weg für den Erstbestand. Bewusst zuerst: wer zwanzig Teile
hat, will nicht zwanzigmal ein Formular ausfüllen. #}
<form method="post" action="/stapel" enctype="multipart/form-data"
class="karte p-4 space-y-3">
<input type="hidden" name="csrf" value="{{ csrf }}">
<h2 class="font-semibold">Viele auf einmal</h2>
<p class="text-sm text-slate-600">
Erst alles mit der Kamera fotografieren, dann hier alle Fotos
zusammen auswählen. Pro Foto entsteht ein Entwurf, die Angaben
trägst du danach in Ruhe nach.
</p>
{# Ohne "capture": so lässt sich wahlweise fotografieren ODER aus der
Mediathek wählen. Mit capture ginge nur je ein Foto pro Vorgang. #}
<input class="feld py-2" type="file" name="dateien" accept="image/*" multiple required>
<button class="knopf-haupt w-full">Fotos hochladen</button>
</form>
<p class="text-center text-sm text-slate-500 my-5">oder einzeln erfassen</p>
<form method="post" action="/erfassen" enctype="multipart/form-data"
class="karte p-4 space-y-4">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="etikett" for="fotos">Fotos</label>
<input class="feld py-2" id="fotos" type="file" name="dateien"
accept="image/*" multiple>
</div>
{% include "_felder.html" %}
<button class="knopf-haupt w-full">Speichern</button>
</form>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
{% extends "basis.html" %}
{% block inhalt %}
<form class="mb-5 grid grid-cols-2 sm:grid-cols-4 gap-2"
hx-get="/teil/liste" hx-target="#liste" hx-swap="outerHTML"
hx-trigger="change">
<div>
<label class="etikett" for="f-size">Grösse</label>
<select class="feld" id="f-size" name="size">
<option value="">alle</option>
{% for g in groessen %}
<option value="{{ g.label }}" {{ 'selected' if g.label == filter.size }}>
{{ g.label }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="etikett" for="f-kat">Kategorie</label>
<select class="feld" id="f-kat" name="category_id">
<option value="">alle</option>
{% for k in kategorien %}
<option value="{{ k.id }}"
{{ 'selected' if k.id|string == filter.category_id }}>
{{ k.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="etikett" for="f-gender">Für</label>
<select class="feld" id="f-gender" name="gender">
<option value="">alle</option>
<option value="girl" {{ 'selected' if filter.gender == 'girl' }}>Mädchen</option>
<option value="boy" {{ 'selected' if filter.gender == 'boy' }}>Buben</option>
<option value="unisex" {{ 'selected' if filter.gender == 'unisex' }}>egal</option>
</select>
</div>
<div>
<label class="etikett" for="f-status">Zustand</label>
<select class="feld" id="f-status" name="status">
<option value="available" {{ 'selected' if filter.status == 'available' }}>
nur verfügbare</option>
<option value="" {{ 'selected' if not filter.status }}>alle</option>
</select>
</div>
</form>
{% include "_liste.html" %}
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends "basis.html" %}
{% block titel %}Reserviert{% endblock %}
{% block inhalt %}
<div class="max-w-lg mx-auto karte p-6 text-center">
<h1 class="text-xl font-bold">Reserviert für dich</h1>
<p class="mt-2 text-slate-600">
<strong>{{ item.anzeige_titel }}</strong> ist jetzt für dich zurückgelegt.
</p>
<div class="mt-5 text-left">
<p class="etikett">Link zum Aufheben bitte aufbewahren</p>
{# Nur hier sichtbar: mit diesem Link kann die Reservierung
zurückgenommen werden, ohne dass jemand ein Konto braucht. #}
<input class="feld text-xs" readonly value="{{ freigabe_url }}"
onclick="this.select()">
<p class="text-xs text-slate-500 mt-2">
Ohne diesen Link kannst du die Reservierung nicht selbst aufheben
dann hilft nur eine kurze Nachricht.
</p>
</div>
<div class="mt-6 flex gap-2 justify-center">
<a href="/" class="knopf-haupt">Weiterstöbern</a>
<a href="{{ freigabe_url }}" class="knopf-still">Doch nicht</a>
</div>
</div>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
/* Mindestens 44 px hohe Tippflächen - darunter trifft man auf dem
Handy schlecht. */
.knopf {
@apply inline-flex items-center justify-center gap-2 min-h-[44px]
px-4 rounded-xl font-semibold transition;
}
.knopf-haupt { @apply knopf bg-marke-700 text-white hover:bg-marke-800; }
.knopf-still { @apply knopf bg-slate-100 text-slate-700 hover:bg-slate-200; }
.feld {
@apply w-full min-h-[44px] px-3 rounded-xl border border-slate-300
bg-white text-slate-900 focus:outline-none focus:ring-2
focus:ring-marke-600;
}
.etikett { @apply block text-sm font-medium text-slate-600 mb-1; }
.karte { @apply bg-white rounded-2xl shadow-sm overflow-hidden; }
}
+23
View File
@@ -0,0 +1,23 @@
/** Tailwind-Konfiguration.
* Gebaut wird mit der Standalone-CLI im Docker-Build (keine Node.js-
* Abhängigkeit, siehe Dockerfile). Das Ergebnis landet als
* app/static/style.css und ist darum nicht im Repository.
*/
module.exports = {
content: ["./app/templates/**/*.html"],
theme: {
extend: {
colors: {
// Ruhiger Grünton, damit die Fotos die Aufmerksamkeit bekommen.
marke: {
50: "#f0fdfa",
100: "#ccfbf1",
600: "#0d9488",
700: "#0f766e",
800: "#115e59",
},
},
},
},
plugins: [],
};
+223
View File
@@ -0,0 +1,223 @@
"""Tests der sichtbaren Seiten."""
import io
import re
from PIL import Image
def bild_bytes() -> bytes:
puffer = io.BytesIO()
Image.new("RGB", (600, 800), (120, 160, 200)).save(puffer, "JPEG")
return puffer.getvalue()
def csrf_aus(html: str) -> str:
treffer = re.search(r'name="csrf" value="([^"]+)"', html)
assert treffer, "kein CSRF-Token im Formular"
return treffer.group(1)
def erfasse(client, kategorie_id, **felder):
"""Legt über das Formular ein Kleidungsstück an."""
seite = client.get("/erfassen")
daten = {
"csrf": csrf_aus(seite.text),
"size": "98/104",
"category_id": str(kategorie_id),
"gender": "unisex",
"season": "all_year",
"condition": "good",
"title": "",
"description": "",
}
daten.update(felder)
return client.post("/erfassen", data=daten, follow_redirects=True)
# --------------------------------------------------------------- Galerie ---
def test_galerie_ist_ohne_anmeldung_sichtbar(gast):
antwort = gast.get("/")
assert antwort.status_code == 200
assert "Kleider-Börse" in antwort.text
def test_galerie_zeigt_erfasstes(client, gast, kategorie_id):
erfasse(client, kategorie_id, title="Rote Winterjacke")
client.post("/abmelden", data={"csrf": csrf_aus(client.get("/").text)})
seite = gast.get("/")
assert "Rote Winterjacke" in seite.text
def test_abgeleiteter_titel_erscheint(client, kategorie_id):
"""Titel leer gelassen - dann bildet ihn die Anwendung."""
erfasse(client, kategorie_id, size="80")
seite = client.get("/")
assert "80" in seite.text
def test_filter_liefert_nur_die_liste(client, kategorie_id):
"""HTMX tauscht nur den Ausschnitt aus, nicht die ganze Seite."""
erfasse(client, kategorie_id, size="80", title="Kleines Teil")
erfasse(client, kategorie_id, size="122", title="Grosses Teil")
ausschnitt = client.get("/teil/liste?size=80")
assert ausschnitt.status_code == 200
assert "Kleines Teil" in ausschnitt.text
assert "Grosses Teil" not in ausschnitt.text
# Kein vollständiges Dokument
assert "<html" not in ausschnitt.text.lower()
def test_entwuerfe_tauchen_nicht_in_der_galerie_auf(client, db_sitzung):
from app import crud
crud.entwuerfe_anlegen(db_sitzung, 3)
assert "Nachtragen" not in client.get("/").text
# ------------------------------------------------------------- Erfassung ---
def test_erfassen_braucht_anmeldung(gast):
assert gast.get("/erfassen").status_code == 401
def test_erfassen_und_anzeigen(client, kategorie_id):
antwort = erfasse(client, kategorie_id, title="Blaue Hose")
assert antwort.status_code == 200
assert "gespeichert" in antwort.text
detail = client.get("/kleid/1")
assert detail.status_code == 200
assert "Blaue Hose" in detail.text
def test_werte_werden_fuer_das_naechste_teil_gemerkt(client, kategorie_id):
"""Beim Abarbeiten einer Kiste ändert sich meist nur der Titel."""
erfasse(client, kategorie_id, size="122", gender="girl")
formular = client.get("/erfassen").text
assert re.search(r'<option value="122"\s+selected', formular)
assert re.search(r'<option value="girl"\s+selected', formular)
def test_formular_ohne_token_wird_abgewiesen(client, kategorie_id):
antwort = client.post("/erfassen", data={
"csrf": "erfunden", "size": "80", "category_id": str(kategorie_id),
"gender": "unisex", "season": "all_year", "condition": "good",
"title": "", "description": "",
})
assert antwort.status_code == 400
# ----------------------------------------------------------- Stapelweise ---
def test_stapel_import_ueber_das_formular(client):
seite = client.get("/erfassen")
antwort = client.post(
"/stapel",
data={"csrf": csrf_aus(seite.text)},
files=[("dateien", (f"f{i}.jpg", bild_bytes(), "image/jpeg")) for i in range(4)],
follow_redirects=True,
)
assert antwort.status_code == 200
assert "4 Fotos hochgeladen" in antwort.text
assert "Unfertige Einträge (4)" in antwort.text
def test_entwurf_nachtragen_macht_ihn_sichtbar(client, kategorie_id):
seite = client.get("/erfassen")
client.post(
"/stapel",
data={"csrf": csrf_aus(seite.text)},
files={"dateien": ("f.jpg", bild_bytes(), "image/jpeg")},
follow_redirects=True,
)
formular = client.get("/kleid/1/bearbeiten")
assert formular.status_code == 200
assert "Entwurf nachtragen" in formular.text
fertig = client.post("/kleid/1/bearbeiten", data={
"csrf": csrf_aus(formular.text), "size": "92",
"category_id": str(kategorie_id), "gender": "boy",
"season": "all_year", "condition": "good", "title": "", "description": "",
}, follow_redirects=True)
assert fertig.status_code == 200
# Jetzt in der Galerie
galerie = client.get("/")
assert "92" in galerie.text
# ---------------------------------------------------------- Reservierung ---
def test_reservieren_zeigt_den_freigabe_link(client, gast, kategorie_id):
erfasse(client, kategorie_id, title="Warme Jacke")
client.post("/abmelden", data={"csrf": csrf_aus(client.get("/").text)})
detail = gast.get("/kleid/1")
antwort = gast.post("/kleid/1/reservieren", data={
"csrf": csrf_aus(detail.text), "reserved_by": "Familie Meier",
})
assert antwort.status_code == 200
assert "Reserviert für dich" in antwort.text
# Absolute Adresse - der Link soll sich aufbewahren und weiterschicken
# lassen, ein reiner Pfad nützte dabei nichts.
link = re.search(r'value="(https?://[^"]*/kleid/1/freigeben\?token=[^"]+)"',
antwort.text)
assert link, f"kein Freigabe-Link angezeigt:\n{antwort.text[-600:]}"
# Der Link hebt die Reservierung wieder auf
auf = gast.get(link.group(1), follow_redirects=True)
assert "aufgehoben" in auf.text
def test_name_bleibt_oeffentlich_verborgen(client, gast, kategorie_id):
"""Die Galerie ist frei zugänglich - der Name gehört nicht hinein."""
erfasse(client, kategorie_id, title="Warme Jacke")
detail = client.get("/kleid/1")
client.post("/kleid/1/reservieren", data={
"csrf": csrf_aus(detail.text), "reserved_by": "Familie Geheim",
})
client.post("/abmelden", data={"csrf": csrf_aus(client.get("/").text)})
assert "Familie Geheim" not in gast.get("/").text
assert "Familie Geheim" not in gast.get("/kleid/1").text
assert "reserviert" in gast.get("/kleid/1").text
def test_betreiber_sieht_den_namen(client, kategorie_id):
erfasse(client, kategorie_id, title="Warme Jacke")
detail = client.get("/kleid/1")
client.post("/kleid/1/reservieren", data={
"csrf": csrf_aus(detail.text), "reserved_by": "Familie Meier",
})
assert "Familie Meier" in client.get("/kleid/1").text
def test_falscher_freigabe_link_aendert_nichts(client, gast, kategorie_id):
erfasse(client, kategorie_id)
detail = client.get("/kleid/1")
client.post("/kleid/1/reservieren", data={
"csrf": csrf_aus(detail.text), "reserved_by": "Wer auch immer",
})
client.post("/abmelden", data={"csrf": csrf_aus(client.get("/").text)})
antwort = gast.get("/kleid/1/freigeben?token=daneben", follow_redirects=True)
assert "passt nicht" in antwort.text
assert "reserviert" in gast.get("/kleid/1").text
# ---------------------------------------------------------------- Seiten ---
def test_seiten_laden_nichts_von_fremden_servern(client, kategorie_id):
"""Sonst blockiert die CSP es - und die Seite bliebe leer."""
erfasse(client, kategorie_id)
for pfad in ["/", "/kleid/1", "/erfassen", "/anmelden", "/entwuerfe"]:
html = client.get(pfad).text
fremde = re.findall(r'(?:src|href)="(?:https?:)?//[^"]+"', html)
assert not fremde, f"{pfad} lädt von aussen: {fremde}"