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
|
||||
|
||||
# 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
@@ -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")
|
||||
|
||||
@@ -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