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:
+18
@@ -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
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# libheif wird von pillow-heif mitgeliefert; hier nur die Laufzeit-Bibliotheken,
|
# 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.ini ./
|
||||||
COPY alembic/ ./alembic/
|
COPY alembic/ ./alembic/
|
||||||
COPY app/ ./app/
|
COPY app/ ./app/
|
||||||
|
COPY --from=css /build/style.css ./app/static/style.css
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/kleiderboerse-entrypoint.sh
|
COPY docker-entrypoint.sh /usr/local/bin/kleiderboerse-entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/kleiderboerse-entrypoint.sh
|
RUN chmod +x /usr/local/bin/kleiderboerse-entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
+2
-28
@@ -19,7 +19,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
|
|
||||||
from app.config import einstellungen
|
from app.config import einstellungen
|
||||||
from app.database import SessionLocal, engine
|
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.security import limiter
|
||||||
from app.seed import stammdaten_anlegen
|
from app.seed import stammdaten_anlegen
|
||||||
|
|
||||||
@@ -120,6 +120,7 @@ app.include_router(auth.router)
|
|||||||
app.include_router(categories.router)
|
app.include_router(categories.router)
|
||||||
app.include_router(items.router)
|
app.include_router(items.router)
|
||||||
app.include_router(images.router)
|
app.include_router(images.router)
|
||||||
|
app.include_router(pages.router)
|
||||||
|
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
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)
|
@app.get("/robots.txt", include_in_schema=False)
|
||||||
def robots():
|
def robots():
|
||||||
return PlainTextResponse("User-agent: *\nDisallow: /\n")
|
return PlainTextResponse("User-agent: *\nDisallow: /\n")
|
||||||
|
|||||||
@@ -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.")
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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">← Ü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 %}
|
||||||
@@ -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 →</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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
};
|
||||||
@@ -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}"
|
||||||
Reference in New Issue
Block a user