Datenschutz: Bild-Metadaten, private Vorschläge, fremde Bilder, API-Doku

- Uploads: Server entfernt EXIF/XMP/Text (GPS-Standort, Kamera, Uhrzeit)
  per Pillow; Drehung von Hochkant-Fotos wird vorher angewendet, Farbprofil
  bleibt; GIFs unverändert (neue Abhängigkeit pillow)
- Produktvorschläge pro Liste (Tabelle list_products): Einträge privater
  Listen tauchen bei anderen weder in Vorschlägen noch beim Rezeptabgleich
  auf; "Vorschläge verwalten" löscht nur aus eigenen sichtbaren Listen
- Artikel: Bilder nur noch aus den eigenen Uploads (kein Tracking-Pixel
  fremder Server); Bereinigung in htmlclean.py
- /api/docs und /api/openapi.json nur noch im Entwicklungsmodus
  (RELOAD=1 oder P42_API_DOCS=1)
- Migration Schritt 2: bestehende Vorschläge den Listen zuordnen, Artikel
  neu bereinigen, gespeicherte Bilder mit Metadaten säubern
- Tests in test_datenschutz.py (u. a. Rezept-Zuordnung, Umstellung)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 18:48:19 +02:00
1 parent 419d200bf9
commit bb20135876
11 files changed
+367 -61

No files matched your search

+38 -14
View File
@@ -82,20 +82,39 @@ def _sort_key(name: str) -> str:
return _key(name).translate(_UMLAUTS)
def _canonical_name(conn, name: str) -> str:
"""Schreibweise aus dem Katalog übernehmen bzw. neues Produkt aufnehmen."""
def _canonical_name(conn, name: str, list_id=None) -> str:
"""Schreibweise aus dem Katalog übernehmen bzw. neues Produkt aufnehmen; Nutzung in der Liste mitzählen."""
row = conn.execute("SELECT name FROM products WHERE key = ?", (_key(name),)).fetchone()
if row:
conn.execute("UPDATE products SET uses = uses + 1, last_used = ? WHERE key = ?", (now_iso(), _key(name)))
return row["name"]
if conn.execute("SELECT COUNT(*) FROM products").fetchone()[0] >= MAX_PRODUCTS:
raise ValidationError("Der Produktkatalog ist voll. Bitte alte Vorschläge löschen.")
# Neue Produkte mit großem Anfangsbuchstaben, damit "milch" und "Milch" nicht doppelt entstehen
name = name[0].upper() + name[1:]
conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 1, ?)", (_key(name), name, now_iso()))
name = row["name"]
else:
if conn.execute("SELECT COUNT(*) FROM products").fetchone()[0] >= MAX_PRODUCTS:
raise ValidationError("Der Produktkatalog ist voll. Bitte alte Vorschläge löschen.")
# Neue Produkte mit großem Anfangsbuchstaben, damit "milch" und "Milch" nicht doppelt entstehen
name = name[0].upper() + name[1:]
conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 1, ?)", (_key(name), name, now_iso()))
if list_id is not None:
conn.execute(
"""INSERT INTO list_products (list_id, key, uses, last_used) VALUES (?, ?, 1, ?)
ON CONFLICT (list_id, key) DO UPDATE SET uses = uses + 1, last_used = excluded.last_used""",
(list_id, _key(name), now_iso()),
)
return name
def visible_products(conn, user: dict) -> list:
"""Produkte aus den Listen, die das Konto sehen darf – häufig genutzte zuerst."""
rows = conn.execute(
f"""SELECT p.name, SUM(lp.uses) AS uses FROM list_products lp
JOIN products p ON p.key = lp.key
JOIN shopping_lists l ON l.id = lp.list_id
WHERE {_VISIBLE} GROUP BY p.key""",
_who(user),
).fetchall()
return [r["name"] for r in sorted(rows, key=lambda r: (-r["uses"], _sort_key(r["name"])))]
def _add(conn, name: str, list_: str, list_id: int, quantity, unit: str, user_id: int) -> None:
"""Produkt in den Vorrat bzw. Einkauf einer Liste setzen; steht es dort schon in derselben Einheit, wird die Menge erhöht.
@@ -199,13 +218,11 @@ def _state(conn, user: dict) -> dict:
WHERE i.list_id IN ({','.join('?' * len(ids))})""",
ids,
).fetchall()
# Häufig genutzte Produkte zuerst vorschlagen
products = conn.execute("SELECT name, uses FROM products").fetchall()
return {
"items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))],
"lists": [_shopping_list(conn, r, user) for r in lists],
"users": user_list(conn),
"products": [r["name"] for r in sorted(products, key=lambda r: (-r["uses"], _sort_key(r["name"])))],
"products": visible_products(conn, user),
}
@@ -225,7 +242,7 @@ def add_item(data: dict = Body(...), user: dict = Depends(current_user)):
quantity = _quantity(data.get("quantity", 1), unit)
with transaction() as conn:
list_id = _get_list(conn, data.get("listId"), user)["id"]
_add(conn, _canonical_name(conn, name), list_, list_id, quantity, unit, user["id"])
_add(conn, _canonical_name(conn, name, list_id), list_, list_id, quantity, unit, user["id"])
return _state(conn, user)
@@ -343,9 +360,16 @@ def delete_list(list_id: int, user: dict = Depends(current_user)):
@router.delete("/api/haushalt/produkte")
def delete_product(name: str, user: dict = Depends(current_user)):
"""Vorschlag aus dem Katalog entfernen (z. B. Tippfehler). Einträge auf den Listen bleiben."""
"""Vorschlag entfernen (z. B. Tippfehler) – nur aus den Listen, die das Konto sieht. Einträge auf den Listen bleiben."""
with transaction() as conn:
cur = conn.execute("DELETE FROM products WHERE key = ?", (_key(name),))
ids = [r["id"] for r in _visible_lists(conn, user)]
cur = conn.execute(
f"DELETE FROM list_products WHERE key = ? AND list_id IN ({','.join('?' * len(ids))})", (_key(name), *ids)
)
if not cur.rowcount:
raise HTTPException(404, "Vorschlag nicht gefunden.")
# Schreibweise nur löschen, wenn keine Liste das Produkt mehr kennt
conn.execute(
"DELETE FROM products WHERE key = ? AND NOT EXISTS (SELECT 1 FROM list_products WHERE key = ?)", (_key(name), _key(name))
)
return _state(conn, user)