Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

354
backend/app/routers/sync.py Normal file
View File

@@ -0,0 +1,354 @@
"""Synchronisation: Stapelverarbeitung aus der Outbox und Ereigniskanal.
Zwei Endpunkte:
POST /api/lists/{id}/ops Der Client schickt seine gesammelten
Operationen. Jede trägt eine selbst
vergebene `op_id`; bereits verarbeitete
werden erkannt und übersprungen.
GET /api/lists/{id}/events Server-Sent Events. Meldet nur den neuen
Revisionsstand, keine Nutzdaten - der
Client holt die Ansicht dann selbst.
Warum kein Delta-Endpunkt: Eine Einkaufsliste hat Dutzende Einträge, keine
Zehntausende. Die vollständige Ansicht neu zu holen kostet ein paar
Kilobyte und spart eine ganze Klasse von Fehlern, die beim Zusammensetzen
von Teilständen entsteht. Der Revisionszähler sorgt dafür, dass das nur
passiert, wenn sich wirklich etwas geändert hat.
"""
import asyncio
import json
import logging
import anyio
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.deps import DbSession, VerifiedUser, current_user
from app.list_view import build_view
from app.models import AppliedOp, Article, Category, ListItem, ListMember, Market, ShoppingList
from app.permissions import EditableList, ReadableList, bump_rev
from app.prices import record_price
from app.push import notify_list_changed
from app.schemas_sync import OpBatchIn, OpBatchOut, OpResult
from app.security import utcnow
log = logging.getLogger(__name__)
router = APIRouter(tags=["sync"])
MAX_OPS_PER_BATCH = 200
POLL_SECONDS = 2
HEARTBEAT_SECONDS = 20
# ==========================================================================
# Operationen anwenden
# ==========================================================================
def _ref(db: Session, model, obj_id: str | None, list_id: str, label: str):
if obj_id is None:
return None
obj = db.get(model, obj_id)
if obj is None or obj.list_id != list_id or obj.deleted_at is not None:
raise ValueError(f"{label} nicht gefunden")
return obj
def _apply(db: Session, lst: ShoppingList, user_id: str, op, rev: int) -> str | None:
"""Führt eine einzelne Operation aus und gibt bei item.create die
vergebene ID zurück. Wirft ValueError bei fachlichen Fehlern - die
lässt der Aufrufer als abgelehnte Operation durchgehen, ohne den
ganzen Stapel scheitern zu lassen."""
data = op.payload
kind = op.kind
if kind == "item.create":
article = None
# Bekannter Artikel, etwa nach einem Strichcode-Treffer.
if data.get("article_id"):
article = db.get(Article, data["article_id"])
if (article is None or article.list_id != lst.id
or article.deleted_at is not None):
raise ValueError("Artikel nicht gefunden")
# Sonst über den Barcode suchen ...
if article is None and data.get("barcode"):
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.barcode == data["barcode"],
Article.deleted_at.is_(None),
)
)
# ... und zuletzt über den Namen.
if article is None:
name = (data.get("article_name") or "").strip()
if not name:
raise ValueError("Artikelname fehlt")
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.name == name,
Article.deleted_at.is_(None),
)
)
if article is None:
article = Article(
list_id=lst.id,
name=name[:200],
barcode=(data.get("barcode") or None),
row_rev=rev,
)
db.add(article)
db.flush()
elif data.get("barcode") and not article.barcode:
# Der Artikel war schon da, hatte aber noch keinen Code -
# beim nächsten Scan wird er direkt gefunden.
article.barcode = data["barcode"]
article.row_rev = rev
market = _ref(db, Market, data.get("market_id"), lst.id, "Markt")
category = _ref(db, Category, data.get("category_id"), lst.id, "Warengruppe")
item = ListItem(
list_id=lst.id,
article_id=article.id,
market_id=(market.id if market else article.default_market_id),
category_id=(category.id if category else article.default_category_id),
count=int(data.get("count") or 1),
# "quantity"/"unit" sind die alten Feldnamen. Ein Geraet, das
# zum Zeitpunkt der Umstellung noch Operationen in der Outbox
# hatte, schickt sie weiterhin - die landen jetzt als Gebinde,
# was der bisherigen Bedeutung entspricht.
pack_size=data.get("pack_size", data.get("quantity")),
pack_unit=(data.get("pack_unit") or data.get("unit") or None),
variant=(data.get("variant") or None),
note=(data.get("note") or None),
status="open",
created_by=user_id,
row_rev=rev,
)
db.add(item)
db.flush()
return item.id
if kind == "item.update":
item = db.get(ListItem, data.get("item_id"))
if item is None or item.list_id != lst.id or item.deleted_at is not None:
raise ValueError("Eintrag nicht gefunden")
if data.get("clear_market"):
item.market_id = None
elif "market_id" in data:
market = _ref(db, Market, data["market_id"], lst.id, "Markt")
item.market_id = market.id if market else None
if data.get("clear_category"):
item.category_id = None
elif "category_id" in data:
category = _ref(db, Category, data["category_id"], lst.id, "Warengruppe")
item.category_id = category.id if category else None
# Alte Feldnamen aus einer Outbox von vor der Umstellung.
if "quantity" in data and "pack_size" not in data:
data["pack_size"] = data["quantity"]
if "unit" in data and "pack_unit" not in data:
data["pack_unit"] = data["unit"]
for field in ("count", "pack_size", "pack_unit", "variant", "note",
"status", "price_cents"):
if field not in data:
continue
# count und status sind nicht leerbar - ein Eintrag ohne
# Stückzahl oder Status waere unvollstaendig.
if field in ("count", "status") and data[field] is None:
continue
setattr(item, field, data[field])
item.row_rev = rev
record_price(db, item)
return item.id
if kind == "item.delete":
item = db.get(ListItem, data.get("item_id"))
if item is None or item.list_id != lst.id:
raise ValueError("Eintrag nicht gefunden")
if item.deleted_at is None:
item.deleted_at = utcnow()
item.row_rev = rev
return item.id
if kind == "items.clear_bought":
now = utcnow()
for item in db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.status == "bought",
ListItem.deleted_at.is_(None),
)
).all():
item.deleted_at = now
item.row_rev = rev
return None
raise ValueError(f"Unbekannte Operation: {kind}")
@router.post("/api/lists/{list_id}/ops", response_model=OpBatchOut)
def apply_ops(
payload: OpBatchIn,
lst: EditableList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
"""Verarbeitet einen Stapel Operationen.
Der gesamte Stapel bekommt EINEN Revisionsschritt. So sieht ein
anderes Gerät eine geschlossene Änderung statt dutzender einzelner -
und die Anzeige flackert nicht.
Fachlich fehlgeschlagene Operationen werden einzeln als abgelehnt
zurückgemeldet, statt den ganzen Stapel scheitern zu lassen. Sonst
würde ein Eintrag, den jemand anders inzwischen gelöscht hat, alle
übrigen Änderungen des Geräts blockieren.
"""
if len(payload.ops) > MAX_OPS_PER_BATCH:
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
f"Höchstens {MAX_OPS_PER_BATCH} Operationen je Aufruf.",
)
results: list[OpResult] = []
rev = lst.rev
changed = False
for op in payload.ops:
known = db.get(AppliedOp, op.op_id)
if known is not None:
# Schon verarbeitet - Quittung wiederholen, nichts tun.
results.append(
OpResult(op_id=op.op_id, status="duplicate", result_id=known.result_id)
)
continue
if not changed:
rev = bump_rev(db, lst.id)
changed = True
try:
result_id = _apply(db, lst, user.id, op, rev)
except ValueError as exc:
db.rollback()
# Nach dem Rollback ist der Revisionsschritt weg - beim
# nächsten erfolgreichen Vorgang wird neu gezogen.
changed = False
results.append(OpResult(op_id=op.op_id, status="rejected", error=str(exc)))
continue
db.add(
AppliedOp(
op_id=op.op_id, list_id=lst.id, user_id=user.id,
kind=op.kind, result_id=result_id, rev=rev,
)
)
db.flush()
results.append(OpResult(op_id=op.op_id, status="applied", result_id=result_id))
db.commit()
db.refresh(lst)
# Nur benachrichtigen, wenn wirklich etwas passiert ist. Ein Stapel,
# der nur aus Wiederholungen bestand, aendert nichts.
if any(r.status == "applied" for r in results):
background.add_task(notify_list_changed, lst.id, user.id)
return OpBatchOut(rev=lst.rev, results=results)
# ==========================================================================
# Ereigniskanal
# ==========================================================================
def _read_rev(list_id: str, user_id: str) -> int | None:
"""Läuft im Threadpool: SQLAlchemy ist hier synchron konfiguriert,
und ein blockierender Aufruf in der Ereignisschleife würde den
gesamten Server anhalten."""
with SessionLocal() as db:
if db.get(ListMember, (list_id, user_id)) is None:
return None
lst = db.scalar(
select(ShoppingList).where(
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
)
)
return lst.rev if lst else None
@router.get("/api/lists/{list_id}/events")
async def events(list_id: str, request: Request, lst: ReadableList, user: VerifiedUser):
"""Meldet Änderungen an der Liste als Server-Sent Events.
Ohne Redis wird der Revisionsstand abgefragt statt verteilt - bei
dieser Größenordnung völlig ausreichend. Wichtig ist nur, dass die
Abfrage im Threadpool läuft.
Achtung beim Reverse Proxy: `proxy_buffering off` ist Pflicht, sonst
sammelt nginx die Ereignisse und liefert sie gebündelt aus.
"""
user_id = user.id
last_rev = lst.rev
async def stream():
nonlocal last_rev
# Erstes Ereignis sofort, damit der Client seinen Stand abgleicht.
yield f"event: rev\ndata: {json.dumps({'rev': last_rev})}\n\n"
# Wiederverbindungsabstand für den Browser.
yield "retry: 5000\n\n"
since_heartbeat = 0.0
while True:
if await request.is_disconnected():
break
await asyncio.sleep(POLL_SECONDS)
since_heartbeat += POLL_SECONDS
try:
rev = await anyio.to_thread.run_sync(_read_rev, list_id, user_id)
except Exception:
log.exception("Ereigniskanal: Abfrage fehlgeschlagen")
break
if rev is None:
# Liste gelöscht oder Zugriff entzogen.
yield "event: gone\ndata: {}\n\n"
break
if rev != last_rev:
last_rev = rev
since_heartbeat = 0.0
yield f"event: rev\ndata: {json.dumps({'rev': rev})}\n\n"
elif since_heartbeat >= HEARTBEAT_SECONDS:
since_heartbeat = 0.0
# Kommentarzeile: hält die Verbindung durch Proxys offen,
# löst beim Client aber kein Ereignis aus.
yield ": ping\n\n"
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)