355 lines
13 KiB
Python
355 lines
13 KiB
Python
"""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",
|
|
},
|
|
)
|