"""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", }, )