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:
1 parent
419d200bf9
commit
bb20135876
11 files changed
+362
-56
No files matched your search
@@ -57,7 +57,8 @@ python3 -m pip --python .venv/bin/python install -r requirements-dev.txt # ein
|
|||||||
|
|
||||||
Jeder Test bekommt eine frische, leere Datenbank. `tests/test_migrationen.py` baut ältere
|
Jeder Test bekommt eine frische, leere Datenbank. `tests/test_migrationen.py` baut ältere
|
||||||
Datenbank-Stände nach und prüft, dass der Start sie verlustfrei umstellt.
|
Datenbank-Stände nach und prüft, dass der Start sie verlustfrei umstellt.
|
||||||
Die API-Dokumentation gibt es unter http://localhost:3000/api/docs.
|
Die API-Dokumentation gibt es nur im Entwicklungsmodus (`RELOAD=1` oder `P42_API_DOCS=1`) unter
|
||||||
|
http://localhost:3000/api/docs – im Betrieb ist sie abgeschaltet.
|
||||||
|
|
||||||
## Konfiguration (`config.json`)
|
## Konfiguration (`config.json`)
|
||||||
|
|
||||||
@@ -137,7 +138,11 @@ wird der Artikel direkt auf der Seite geändert. Bilder lassen sich per Button,
|
|||||||
Einfügen (Strg+V) oder Drag & Drop in den Text setzen.
|
Einfügen (Strg+V) oder Drag & Drop in den Text setzen.
|
||||||
|
|
||||||
Nicht mehr verwendete Bilder werden automatisch gelöscht. Der Artikeltext wird beim
|
Nicht mehr verwendete Bilder werden automatisch gelöscht. Der Artikeltext wird beim
|
||||||
Speichern serverseitig bereinigt.
|
Speichern serverseitig bereinigt; Bilder sind nur aus den eigenen Uploads erlaubt (fremde Bilder
|
||||||
|
würden beim Lesen IP-Adresse und Zeitpunkt an deren Server verraten).
|
||||||
|
|
||||||
|
Beim Hochladen entfernt der Server alle Metadaten aus Fotos (EXIF/XMP: GPS-Standort, Kamera,
|
||||||
|
Uhrzeit); die Drehung von Hochkant-Fotos bleibt erhalten. GIFs bleiben unverändert.
|
||||||
|
|
||||||
### Vorrat & Einkauf
|
### Vorrat & Einkauf
|
||||||
|
|
||||||
@@ -151,6 +156,9 @@ und einen eigenen Einkaufszettel.
|
|||||||
Administratoren aber nur bei Listen, die sie sehen. Löschen entfernt Vorrat und Einkauf der Liste.
|
Administratoren aber nur bei Listen, die sie sehen. Löschen entfernt Vorrat und Einkauf der Liste.
|
||||||
- Beim Update werden der bisherige Vorrat und die bisherige Einkaufsliste zur Liste „Zuhause“
|
- Beim Update werden der bisherige Vorrat und die bisherige Einkaufsliste zur Liste „Zuhause“
|
||||||
(für alle sichtbar).
|
(für alle sichtbar).
|
||||||
|
- **Vorschläge** beim Eintippen und die Zuordnung beim Rezeptabgleich kommen nur aus Listen, die man
|
||||||
|
sehen darf – Einträge privater Listen tauchen bei anderen nicht auf. „Vorschläge verwalten“ löscht
|
||||||
|
nur aus den eigenen sichtbaren Listen.
|
||||||
- Welche Liste gewählt ist, merkt sich der Browser. Gekauft, aufgebraucht, Nachkaufen und der
|
- Welche Liste gewählt ist, merkt sich der Browser. Gekauft, aufgebraucht, Nachkaufen und der
|
||||||
Rezeptabgleich wirken immer innerhalb der gewählten Liste.
|
Rezeptabgleich wirken immer innerhalb der gewählten Liste.
|
||||||
- **Einkaufen:** Abhaken legt ein Produkt „in den Wagen“ – es rutscht nach unten, bleibt aber auf der
|
- **Einkaufen:** Abhaken legt ein Produkt „in den Wagen“ – es rutscht nach unten, bleibt aber auf der
|
||||||
@@ -237,7 +245,9 @@ backend/
|
|||||||
app/db.py SQLite-Schema (neuester Stand), Verbindungen, Übernahme alter JSON-Daten
|
app/db.py SQLite-Schema (neuester Stand), Verbindungen, Übernahme alter JSON-Daten
|
||||||
app/migrations.py Nummerierte Umstellungen bestehender Datenbanken (Regel steht oben in der Datei)
|
app/migrations.py Nummerierte Umstellungen bestehender Datenbanken (Regel steht oben in der Datei)
|
||||||
app/auth.py Konten, Anmeldung, Benutzerverwaltung
|
app/auth.py Konten, Anmeldung, Benutzerverwaltung
|
||||||
app/projects.py Projekte/Artikel, Bild-Uploads, HTML-Bereinigung
|
app/projects.py Projekte/Artikel, Bild-Uploads
|
||||||
|
app/htmlclean.py Artikel-HTML bereinigen (nur eigene Bilder)
|
||||||
|
app/bilder.py Metadaten (GPS usw.) aus hochgeladenen Bildern entfernen
|
||||||
app/links.py Schnellzugriff-Links pro Konto
|
app/links.py Schnellzugriff-Links pro Konto
|
||||||
app/monitoring.py Monitoring pro Konto (HTTP/TCP-Prüfung, P42-S.M.-Auslastung + Verlauf)
|
app/monitoring.py Monitoring pro Konto (HTTP/TCP-Prüfung, P42-S.M.-Auslastung + Verlauf)
|
||||||
app/haushalt.py Listen (je Vorrat + Einkauf) mit Sichtbarkeit, Produktkatalog, Einheiten
|
app/haushalt.py Listen (je Vorrat + Einkauf) mit Sichtbarkeit, Produktkatalog, Einheiten
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Hochgeladene Bilder ohne Metadaten speichern.
|
||||||
|
|
||||||
|
Fotos vom Handy enthalten in den EXIF-Daten oft den Aufnahmeort (GPS), das Gerät und die Uhrzeit.
|
||||||
|
Die Bilder sind für alle Konten sichtbar, deshalb wird alles außer den Bildpunkten entfernt.
|
||||||
|
Die Drehung aus den EXIF-Daten wird vorher angewendet, damit Hochkant-Fotos richtig stehen bleiben.
|
||||||
|
Nur das Farbprofil (ICC) bleibt erhalten, damit sich die Farben nicht verändern.
|
||||||
|
|
||||||
|
GIFs bleiben unverändert: Sie haben keine EXIF-/GPS-Daten, und neu speichern würde Animationen verschlechtern.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
from .common import ValidationError
|
||||||
|
|
||||||
|
# Schutz vor "Dekompressionsbomben" (winzige Datei, riesiges Bild)
|
||||||
|
Image.MAX_IMAGE_PIXELS = 60_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def hat_metadaten(data: bytes) -> bool:
|
||||||
|
"""Enthält das Bild EXIF-, XMP- oder Text-Angaben (z. B. GPS, Kamera, Kommentar)?"""
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(data)) as img:
|
||||||
|
return bool(img.getexif()) or any(k in img.info for k in ("xmp", "XML:com.adobe.xmp", "comment", "exif")) \
|
||||||
|
or bool(getattr(img, "text", None))
|
||||||
|
except (OSError, ValueError, Image.DecompressionBombError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def ohne_metadaten(data: bytes, ext: str) -> bytes:
|
||||||
|
"""Bild neu schreiben, ohne EXIF/XMP/Text-Angaben. ext: jpg, png, webp oder gif."""
|
||||||
|
if ext == "gif":
|
||||||
|
return data
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(data)) as img:
|
||||||
|
animated = getattr(img, "is_animated", False)
|
||||||
|
icc = img.info.get("icc_profile")
|
||||||
|
out = io.BytesIO()
|
||||||
|
if ext == "webp" and animated:
|
||||||
|
# Animierte WebP: alle Bilder übernehmen, Metadaten fallen weg
|
||||||
|
img.save(out, "WEBP", save_all=True, quality=90, icc_profile=icc, duration=img.info.get("duration"), loop=img.info.get("loop", 0))
|
||||||
|
return out.getvalue()
|
||||||
|
img = ImageOps.exif_transpose(img)
|
||||||
|
if ext == "jpg":
|
||||||
|
if img.mode not in ("RGB", "L", "CMYK"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(out, "JPEG", quality=92, optimize=True, icc_profile=icc)
|
||||||
|
elif ext == "png":
|
||||||
|
img.save(out, "PNG", optimize=True, icc_profile=icc)
|
||||||
|
else:
|
||||||
|
img.save(out, "WEBP", quality=90, icc_profile=icc)
|
||||||
|
return out.getvalue()
|
||||||
|
except (OSError, ValueError, Image.DecompressionBombError):
|
||||||
|
raise ValidationError("Das Bild konnte nicht gelesen werden oder ist zu groß.")
|
||||||
+10
-1
@@ -138,7 +138,7 @@ CREATE TABLE IF NOT EXISTS shopping_list_members (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS household_items {HOUSEHOLD_TABLE};
|
CREATE TABLE IF NOT EXISTS household_items {HOUSEHOLD_TABLE};
|
||||||
|
|
||||||
-- Produktkatalog für die Vorschläge
|
-- Produktkatalog: einheitliche Schreibweise ("milch" → "Milch")
|
||||||
CREATE TABLE IF NOT EXISTS products (
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -146,6 +146,15 @@ CREATE TABLE IF NOT EXISTS products (
|
|||||||
last_used TEXT NOT NULL
|
last_used TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Welche Produkte in welcher Liste vorkamen: Vorschläge sieht man nur aus Listen, die man sehen darf
|
||||||
|
CREATE TABLE IF NOT EXISTS list_products (
|
||||||
|
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
uses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_used TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (list_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
-- Gelernte Zuordnungen Rezeptzutat → Produkt (z. B. "margarine" → "Butter")
|
-- Gelernte Zuordnungen Rezeptzutat → Produkt (z. B. "margarine" → "Butter")
|
||||||
CREATE TABLE IF NOT EXISTS ingredient_aliases (
|
CREATE TABLE IF NOT EXISTS ingredient_aliases (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
|
|||||||
+33
-9
@@ -82,20 +82,39 @@ def _sort_key(name: str) -> str:
|
|||||||
return _key(name).translate(_UMLAUTS)
|
return _key(name).translate(_UMLAUTS)
|
||||||
|
|
||||||
|
|
||||||
def _canonical_name(conn, name: str) -> str:
|
def _canonical_name(conn, name: str, list_id=None) -> str:
|
||||||
"""Schreibweise aus dem Katalog übernehmen bzw. neues Produkt aufnehmen."""
|
"""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()
|
row = conn.execute("SELECT name FROM products WHERE key = ?", (_key(name),)).fetchone()
|
||||||
if row:
|
if row:
|
||||||
conn.execute("UPDATE products SET uses = uses + 1, last_used = ? WHERE key = ?", (now_iso(), _key(name)))
|
conn.execute("UPDATE products SET uses = uses + 1, last_used = ? WHERE key = ?", (now_iso(), _key(name)))
|
||||||
return row["name"]
|
name = row["name"]
|
||||||
|
else:
|
||||||
if conn.execute("SELECT COUNT(*) FROM products").fetchone()[0] >= MAX_PRODUCTS:
|
if conn.execute("SELECT COUNT(*) FROM products").fetchone()[0] >= MAX_PRODUCTS:
|
||||||
raise ValidationError("Der Produktkatalog ist voll. Bitte alte Vorschläge löschen.")
|
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
|
# Neue Produkte mit großem Anfangsbuchstaben, damit "milch" und "Milch" nicht doppelt entstehen
|
||||||
name = name[0].upper() + name[1:]
|
name = name[0].upper() + name[1:]
|
||||||
conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 1, ?)", (_key(name), name, now_iso()))
|
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
|
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:
|
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.
|
"""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))})""",
|
WHERE i.list_id IN ({','.join('?' * len(ids))})""",
|
||||||
ids,
|
ids,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
# Häufig genutzte Produkte zuerst vorschlagen
|
|
||||||
products = conn.execute("SELECT name, uses FROM products").fetchall()
|
|
||||||
return {
|
return {
|
||||||
"items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))],
|
"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],
|
"lists": [_shopping_list(conn, r, user) for r in lists],
|
||||||
"users": user_list(conn),
|
"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)
|
quantity = _quantity(data.get("quantity", 1), unit)
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
list_id = _get_list(conn, data.get("listId"), user)["id"]
|
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)
|
return _state(conn, user)
|
||||||
|
|
||||||
|
|
||||||
@@ -343,9 +360,16 @@ def delete_list(list_id: int, user: dict = Depends(current_user)):
|
|||||||
|
|
||||||
@router.delete("/api/haushalt/produkte")
|
@router.delete("/api/haushalt/produkte")
|
||||||
def delete_product(name: str, user: dict = Depends(current_user)):
|
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:
|
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:
|
if not cur.rowcount:
|
||||||
raise HTTPException(404, "Vorschlag nicht gefunden.")
|
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)
|
return _state(conn, user)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Artikel-HTML bereinigen: nur einfache Formatierung, Links und Bilder aus den eigenen Uploads.
|
||||||
|
|
||||||
|
Bilder von fremden Servern sind nicht erlaubt: Beim Lesen würde der Browser sie dort abrufen und so
|
||||||
|
IP-Adresse und Lesezeitpunkt verraten (Tracking-Pixel). Externe Links bleiben erlaubt (erst ein Klick ruft sie auf).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import nh3
|
||||||
|
|
||||||
|
ALLOWED_TAGS = {"p", "br", "h2", "h3", "strong", "b", "em", "i", "u", "s", "ul", "ol", "li", "blockquote", "hr", "a", "img"}
|
||||||
|
ALLOWED_ATTRS = {"a": {"href"}, "img": {"src", "alt"}}
|
||||||
|
UPLOAD_SRC = re.compile(r"/uploads/[a-f0-9]{24}\.(?:jpg|png|gif|webp)")
|
||||||
|
|
||||||
|
|
||||||
|
def _attribute_filter(tag: str, attr: str, value: str) -> str | None:
|
||||||
|
if tag == "img" and attr == "src" and not UPLOAD_SRC.fullmatch(value):
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize(content: str) -> str:
|
||||||
|
cleaned = nh3.clean(
|
||||||
|
content,
|
||||||
|
tags=ALLOWED_TAGS,
|
||||||
|
clean_content_tags={"script", "style"},
|
||||||
|
attributes=ALLOWED_ATTRS,
|
||||||
|
attribute_filter=_attribute_filter,
|
||||||
|
url_schemes={"http", "https", "mailto"},
|
||||||
|
link_rel="noopener noreferrer",
|
||||||
|
)
|
||||||
|
# Bilder, deren Quelle entfernt wurde, ganz weglassen
|
||||||
|
return re.sub(r"<img(?![^>]*\ssrc=)[^>]*>", "", cleaned).strip()
|
||||||
+9
-1
@@ -4,6 +4,7 @@ Liefert die API unter /api, die hochgeladenen Bilder unter /uploads
|
|||||||
und (falls gebaut) das React-Frontend aus frontend/dist.
|
und (falls gebaut) das React-Frontend aus frontend/dist.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
@@ -33,7 +34,14 @@ async def lifespan(_: FastAPI):
|
|||||||
capitals.cancel()
|
capitals.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="P42 Dashboard", docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=lifespan)
|
# API-Beschreibung nur bei der Entwicklung (RELOAD=1 oder P42_API_DOCS=1) – öffentlich verriete sie alle Schnittstellen
|
||||||
|
API_DOCS = os.environ.get("RELOAD") == "1" or os.environ.get("P42_API_DOCS") == "1"
|
||||||
|
app = FastAPI(
|
||||||
|
title="P42 Dashboard",
|
||||||
|
docs_url="/api/docs" if API_DOCS else None,
|
||||||
|
openapi_url="/api/openapi.json" if API_DOCS else None,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
# Komprimiert u. a. die JS-Bundles und die Weltkarte (~740 KB → ~200 KB)
|
# Komprimiert u. a. die JS-Bundles und die Weltkarte (~740 KB → ~200 KB)
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,16 @@ SCHEMA in db.py beschreibt immer den neuesten Stand. Bei einer Änderung am Sche
|
|||||||
Jeder Schritt läuft genau einmal, in einer Transaktion mit dem Hochzählen der Nummer.
|
Jeder Schritt läuft genau einmal, in einer Transaktion mit dem Hochzählen der Nummer.
|
||||||
Tests: tests/test_migrationen.py (alte Stände nachbauen und umstellen).
|
Tests: tests/test_migrationen.py (alte Stände nachbauen und umstellen).
|
||||||
"""
|
"""
|
||||||
from .common import now_iso
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .bilder import hat_metadaten, ohne_metadaten
|
||||||
|
from .common import ValidationError, now_iso
|
||||||
|
from .config import DATA_DIR
|
||||||
from .db import HOUSEHOLD_TABLE, KANBAN_PROJECTS_TABLE, KANBAN_TASKS_TABLE
|
from .db import HOUSEHOLD_TABLE, KANBAN_PROJECTS_TABLE, KANBAN_TASKS_TABLE
|
||||||
|
from .htmlclean import sanitize
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def start(conn) -> None:
|
def start(conn) -> None:
|
||||||
@@ -176,7 +184,43 @@ def _kanban_projects(conn) -> None:
|
|||||||
conn.execute("DROP TABLE kanban_tasks_alt")
|
conn.execute("DROP TABLE kanban_tasks_alt")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Schritt 2: Datenschutz ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _v2_datenschutz(conn) -> None:
|
||||||
|
"""Vorschläge pro Liste, Artikel ohne fremde Bilder, hochgeladene Bilder ohne Metadaten (GPS usw.)."""
|
||||||
|
# Vorschläge den Listen zuordnen: was gerade auf einer Liste steht, gehört zu dieser Liste;
|
||||||
|
# alles Übrige stammt aus der Zeit vor den privaten Listen und gehört zu den Listen, die alle sehen
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT OR IGNORE INTO list_products (list_id, key, uses, last_used)
|
||||||
|
SELECT DISTINCT i.list_id, p.key, p.uses, p.last_used FROM household_items i JOIN products p ON p.key = i.key"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT OR IGNORE INTO list_products (list_id, key, uses, last_used)
|
||||||
|
SELECT l.id, p.key, p.uses, p.last_used FROM products p JOIN shopping_lists l ON l.everyone = 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM household_items i WHERE i.key = p.key)"""
|
||||||
|
)
|
||||||
|
# Artikel mit den neuen Regeln bereinigen (Bilder nur noch aus den eigenen Uploads)
|
||||||
|
for row in conn.execute("SELECT id, content FROM projects").fetchall():
|
||||||
|
cleaned = sanitize(row["content"] or "")
|
||||||
|
if cleaned != (row["content"] or ""):
|
||||||
|
conn.execute("UPDATE projects SET content = ? WHERE id = ?", (cleaned, row["id"]))
|
||||||
|
# Hochgeladene Bilder: nur solche mit Metadaten neu schreiben (die anderen bleiben unverändert)
|
||||||
|
uploads = DATA_DIR / "uploads"
|
||||||
|
if uploads.is_dir():
|
||||||
|
for f in uploads.iterdir():
|
||||||
|
if not re.fullmatch(r"[a-f0-9]{24}\.(jpg|png|webp)", f.name):
|
||||||
|
continue
|
||||||
|
data = f.read_bytes()
|
||||||
|
if hat_metadaten(data):
|
||||||
|
try:
|
||||||
|
f.write_bytes(ohne_metadaten(data, f.suffix[1:]))
|
||||||
|
except ValidationError:
|
||||||
|
log.warning("Bild %s konnte nicht bereinigt werden", f.name)
|
||||||
|
|
||||||
|
|
||||||
# Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen.
|
# Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen.
|
||||||
STEPS = [
|
STEPS = [
|
||||||
_v1_altbestand,
|
_v1_altbestand,
|
||||||
|
_v2_datenschutz,
|
||||||
]
|
]
|
||||||
+4
-29
@@ -3,14 +3,15 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import nh3
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from .auth import current_user
|
from .auth import current_user
|
||||||
|
from .bilder import ohne_metadaten
|
||||||
from .config import DATA_DIR
|
from .config import DATA_DIR
|
||||||
from .common import ValidationError, clean_text, now_iso
|
from .common import ValidationError, clean_text, now_iso
|
||||||
from .db import transaction
|
from .db import transaction
|
||||||
|
from .htmlclean import sanitize
|
||||||
from .kanban import boards_for_article
|
from .kanban import boards_for_article
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -26,33 +27,6 @@ MAX_UPLOAD = 15 * 1024 * 1024
|
|||||||
MIME = {"jpg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp"}
|
MIME = {"jpg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp"}
|
||||||
|
|
||||||
|
|
||||||
# ---------- Artikel-HTML bereinigen ----------
|
|
||||||
|
|
||||||
ALLOWED_TAGS = {"p", "br", "h2", "h3", "strong", "b", "em", "i", "u", "s", "ul", "ol", "li", "blockquote", "hr", "a", "img"}
|
|
||||||
ALLOWED_ATTRS = {"a": {"href"}, "img": {"src", "alt"}}
|
|
||||||
|
|
||||||
|
|
||||||
def _attribute_filter(tag: str, attr: str, value: str) -> str | None:
|
|
||||||
# Bilder nur aus den eigenen Uploads oder per https
|
|
||||||
if tag == "img" and attr == "src" and not (value.startswith("/uploads/") or value.startswith("https://")):
|
|
||||||
return None
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize(content: str) -> str:
|
|
||||||
cleaned = nh3.clean(
|
|
||||||
content,
|
|
||||||
tags=ALLOWED_TAGS,
|
|
||||||
clean_content_tags={"script", "style"},
|
|
||||||
attributes=ALLOWED_ATTRS,
|
|
||||||
attribute_filter=_attribute_filter,
|
|
||||||
url_schemes={"http", "https", "mailto"},
|
|
||||||
link_rel="noopener noreferrer",
|
|
||||||
)
|
|
||||||
# Bilder, deren Quelle entfernt wurde, ganz weglassen
|
|
||||||
return re.sub(r"<img(?![^>]*\ssrc=)[^>]*>", "", cleaned).strip()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- Validierung ----------
|
# ---------- Validierung ----------
|
||||||
|
|
||||||
def _text(value, field: str, max_len: int, required: bool = False) -> str:
|
def _text(value, field: str, max_len: int, required: bool = False) -> str:
|
||||||
@@ -247,7 +221,8 @@ async def upload(request: Request, _: dict = Depends(current_user)):
|
|||||||
if not ext:
|
if not ext:
|
||||||
raise ValidationError("Bildformat nicht unterstützt (erlaubt: JPG, PNG, GIF, WebP).")
|
raise ValidationError("Bildformat nicht unterstützt (erlaubt: JPG, PNG, GIF, WebP).")
|
||||||
name = f"{secrets.token_hex(12)}.{ext}"
|
name = f"{secrets.token_hex(12)}.{ext}"
|
||||||
(UPLOAD_DIR / name).write_bytes(data)
|
# GPS-Standort, Gerät usw. entfernen: die Bilder sehen alle Konten
|
||||||
|
(UPLOAD_DIR / name).write_bytes(ohne_metadaten(bytes(data), ext))
|
||||||
return {"file": name}
|
return {"file": name}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+16
-12
@@ -16,7 +16,7 @@ from fastapi import APIRouter, Body, Depends
|
|||||||
|
|
||||||
from .auth import current_user
|
from .auth import current_user
|
||||||
from .db import transaction
|
from .db import transaction
|
||||||
from .haushalt import _add, _canonical_name, _clean_name, _get_list, _key, _quantity, _state, _unit
|
from .haushalt import _add, _canonical_name, _clean_name, _get_list, _key, _quantity, _state, _unit, visible_products
|
||||||
from .common import ValidationError
|
from .common import ValidationError
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -245,10 +245,13 @@ def _stem(name: str) -> str:
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
def _matcher(conn):
|
def _matcher(conn, user: dict):
|
||||||
aliases = {r["key"]: r["product"] for r in conn.execute("SELECT key, product FROM ingredient_aliases")}
|
# Nur Produkte aus Listen, die das Konto sehen darf – sonst verriete die Zuordnung fremde private Einträge
|
||||||
products = [r["name"] for r in conn.execute("SELECT name FROM products")]
|
products = visible_products(conn, user)
|
||||||
exact = {_key(p): p for p in products}
|
exact = {_key(p): p for p in products}
|
||||||
|
aliases = {
|
||||||
|
r["key"]: r["product"] for r in conn.execute("SELECT key, product FROM ingredient_aliases") if _key(r["product"]) in exact
|
||||||
|
}
|
||||||
stems = {}
|
stems = {}
|
||||||
for p in products:
|
for p in products:
|
||||||
stems.setdefault(_stem(p), p)
|
stems.setdefault(_stem(p), p)
|
||||||
@@ -278,11 +281,11 @@ def _matcher(conn):
|
|||||||
return match
|
return match
|
||||||
|
|
||||||
|
|
||||||
def _result(conn, title, source, servings, raw_ingredients) -> dict:
|
def _result(conn, user, title, source, servings, raw_ingredients) -> dict:
|
||||||
parsed = [p for p in (parse_ingredient(r) for r in raw_ingredients[:MAX_INGREDIENTS]) if p]
|
parsed = [p for p in (parse_ingredient(r) for r in raw_ingredients[:MAX_INGREDIENTS]) if p]
|
||||||
if not parsed:
|
if not parsed:
|
||||||
raise ValidationError("Keine Zutaten gefunden.")
|
raise ValidationError("Keine Zutaten gefunden.")
|
||||||
match = _matcher(conn)
|
match = _matcher(conn, user)
|
||||||
return {
|
return {
|
||||||
"title": title,
|
"title": title,
|
||||||
"source": source,
|
"source": source,
|
||||||
@@ -295,7 +298,7 @@ def _result(conn, title, source, servings, raw_ingredients) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/haushalt/rezept")
|
@router.post("/api/haushalt/rezept")
|
||||||
async def load_recipe(data: dict = Body(...), _: dict = Depends(current_user)):
|
async def load_recipe(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
"""Zutaten aus einem Rezept-Link ({url}) oder eingefügtem Text ({text, servings}) lesen."""
|
"""Zutaten aus einem Rezept-Link ({url}) oder eingefügtem Text ({text, servings}) lesen."""
|
||||||
url = data.get("url").strip() if isinstance(data.get("url"), str) else ""
|
url = data.get("url").strip() if isinstance(data.get("url"), str) else ""
|
||||||
text = data.get("text") if isinstance(data.get("text"), str) else ""
|
text = data.get("text") if isinstance(data.get("text"), str) else ""
|
||||||
@@ -312,7 +315,7 @@ async def load_recipe(data: dict = Body(...), _: dict = Depends(current_user)):
|
|||||||
ingredients = ingredients.splitlines()
|
ingredients = ingredients.splitlines()
|
||||||
title = html.unescape(str(recipe.get("name") or "Rezept"))[:200]
|
title = html.unescape(str(recipe.get("name") or "Rezept"))[:200]
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
return _result(conn, title, url, _servings(recipe.get("recipeYield")), ingredients)
|
return _result(conn, user, title, url, _servings(recipe.get("recipeYield")), ingredients)
|
||||||
if text.strip():
|
if text.strip():
|
||||||
if len(text) > MAX_TEXT:
|
if len(text) > MAX_TEXT:
|
||||||
raise ValidationError("Der Text ist zu lang.")
|
raise ValidationError("Der Text ist zu lang.")
|
||||||
@@ -320,7 +323,7 @@ async def load_recipe(data: dict = Body(...), _: dict = Depends(current_user)):
|
|||||||
servings = servings if isinstance(servings, int) and 0 < servings <= 100 else None
|
servings = servings if isinstance(servings, int) and 0 < servings <= 100 else None
|
||||||
lines = [line.strip(" \t-•*·") for line in text.splitlines()]
|
lines = [line.strip(" \t-•*·") for line in text.splitlines()]
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
return _result(conn, "Eigene Zutatenliste", None, servings, [line for line in lines if line])
|
return _result(conn, user, "Eigene Zutatenliste", None, servings, [line for line in lines if line])
|
||||||
raise ValidationError("Bitte einen Rezept-Link oder eine Zutatenliste angeben.")
|
raise ValidationError("Bitte einen Rezept-Link oder eine Zutatenliste angeben.")
|
||||||
|
|
||||||
|
|
||||||
@@ -342,9 +345,10 @@ def take_over(data: dict = Body(...), user: dict = Depends(current_user)):
|
|||||||
checked.append((_clean_name(i.get("name")), _quantity(i.get("quantity"), unit), unit))
|
checked.append((_clean_name(i.get("name")), _quantity(i.get("quantity"), unit), unit))
|
||||||
learned = [(_clean_name(a.get("ingredient")), _clean_name(a.get("product"))) for a in aliases if isinstance(a, dict)]
|
learned = [(_clean_name(a.get("ingredient")), _clean_name(a.get("product"))) for a in aliases if isinstance(a, dict)]
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
list_id = _get_list(conn, data.get("listId"), user)["id"] if checked else None # Einkauf dieser Liste
|
# Einkauf dieser Liste; gelernte Zuordnungen zählen als Vorschläge dieser Liste
|
||||||
|
list_id = _get_list(conn, data.get("listId"), user)["id"] if checked or data.get("listId") is not None else None
|
||||||
for ingredient, product in learned:
|
for ingredient, product in learned:
|
||||||
product = _canonical_name(conn, product)
|
product = _canonical_name(conn, product, list_id)
|
||||||
if _key(ingredient) == _key(product):
|
if _key(ingredient) == _key(product):
|
||||||
conn.execute("DELETE FROM ingredient_aliases WHERE key = ?", (_key(ingredient),))
|
conn.execute("DELETE FROM ingredient_aliases WHERE key = ?", (_key(ingredient),))
|
||||||
else:
|
else:
|
||||||
@@ -354,5 +358,5 @@ def take_over(data: dict = Body(...), user: dict = Depends(current_user)):
|
|||||||
(_key(ingredient), product),
|
(_key(ingredient), product),
|
||||||
)
|
)
|
||||||
for name, quantity, unit in checked:
|
for name, quantity, unit in checked:
|
||||||
_add(conn, _canonical_name(conn, name), "einkauf", list_id, quantity, unit, user["id"])
|
_add(conn, _canonical_name(conn, name, list_id), "einkauf", list_id, quantity, unit, user["id"])
|
||||||
return _state(conn, user)
|
return _state(conn, user)
|
||||||
@@ -2,3 +2,4 @@ fastapi==0.143.0
|
|||||||
uvicorn[standard]==0.54.0
|
uvicorn[standard]==0.54.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
nh3==0.3.7
|
nh3==0.3.7
|
||||||
|
pillow==12.3.0
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Datenschutz: keine Metadaten in Bildern, private Listen bleiben privat, keine fremden Bilder, keine offene API-Doku."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from PIL import Image, PngImagePlugin
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.config import DATA_DIR
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
GPS = {1: "N", 2: (52.0, 31.0, 12.0), 3: "E", 4: (13.0, 24.0, 36.0)}
|
||||||
|
|
||||||
|
|
||||||
|
def jpeg_mit_gps(width=60, height=30, orientation=None) -> bytes:
|
||||||
|
exif = Image.Exif()
|
||||||
|
exif[0x8825] = GPS
|
||||||
|
exif[0x010F] = "Testkamera"
|
||||||
|
if orientation:
|
||||||
|
exif[0x0112] = orientation
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (width, height), "red").save(buf, "JPEG", exif=exif.tobytes())
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def upload(client, data: bytes) -> str:
|
||||||
|
res = client.post("/api/uploads", content=data)
|
||||||
|
assert res.status_code == 201, res.text
|
||||||
|
return res.json()["file"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Bilder ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_ohne_gps_und_kamera(login):
|
||||||
|
anna, ben = login("anna"), login("ben")
|
||||||
|
name = upload(anna, jpeg_mit_gps())
|
||||||
|
img = Image.open(io.BytesIO(ben.get(f"/uploads/{name}").content))
|
||||||
|
assert not img.getexif()
|
||||||
|
assert img.size == (60, 30)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hochkant_foto_bleibt_hochkant(login):
|
||||||
|
# Orientierung 6 = 90° gedreht: ohne Anwenden läge das Foto nach dem Entfernen der EXIF-Daten quer
|
||||||
|
name = upload(login("anna"), jpeg_mit_gps(60, 30, orientation=6))
|
||||||
|
assert Image.open(DATA_DIR / "uploads" / name).size == (30, 60)
|
||||||
|
|
||||||
|
|
||||||
|
def test_png_ohne_textangaben(login):
|
||||||
|
info = PngImagePlugin.PngInfo()
|
||||||
|
info.add_text("Comment", "Aufgenommen in der Musterstraße 1")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (10, 10)).save(buf, "PNG", pnginfo=info)
|
||||||
|
name = upload(login("anna"), buf.getvalue())
|
||||||
|
assert not Image.open(DATA_DIR / "uploads" / name).text
|
||||||
|
|
||||||
|
|
||||||
|
def test_kaputtes_bild_abgelehnt(login):
|
||||||
|
res = login("anna").post("/api/uploads", content=b"\xff\xd8\xff" + b"kein bild" * 10)
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Private Listen ----------
|
||||||
|
|
||||||
|
|
||||||
|
def private_liste(anna):
|
||||||
|
lid = anna.post("/api/haushalt/listen", json={"name": "Privat", "everyone": False}).json()["created"]
|
||||||
|
anna.post("/api/haushalt/items", json={"list": "einkauf", "name": "Schwangerschaftstest", "listId": lid})
|
||||||
|
return lid
|
||||||
|
|
||||||
|
|
||||||
|
def test_privates_produkt_nicht_in_fremden_vorschlaegen(login):
|
||||||
|
anna, ben = login("anna"), login("ben")
|
||||||
|
lid = private_liste(anna)
|
||||||
|
assert "Schwangerschaftstest" in anna.get("/api/haushalt").json()["products"]
|
||||||
|
assert "Schwangerschaftstest" not in ben.get("/api/haushalt").json()["products"]
|
||||||
|
# Nach Freigabe sieht Ben es
|
||||||
|
anna.patch(f"/api/haushalt/listen/{lid}", json={"name": "Privat", "everyone": False, "members": [ben.user["id"]]})
|
||||||
|
assert "Schwangerschaftstest" in ben.get("/api/haushalt").json()["products"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rezept_zuordnung_verraet_nichts(login):
|
||||||
|
anna, ben = login("anna"), login("ben")
|
||||||
|
lid = anna.post("/api/haushalt/listen", json={"name": "Privat", "everyone": False}).json()["created"]
|
||||||
|
anna.post("/api/haushalt/items", json={"list": "einkauf", "name": "Kondome", "listId": lid})
|
||||||
|
# Ähnliche Schreibweise würde sonst Annas privaten Eintrag als "bekanntes Produkt" zuordnen
|
||||||
|
ing = ben.post("/api/haushalt/rezept", json={"text": "2 Kondom"}).json()["ingredients"][0]
|
||||||
|
assert ing["known"] is False and ing["product"] != "Kondome"
|
||||||
|
ing = anna.post("/api/haushalt/rezept", json={"text": "2 Kondom"}).json()["ingredients"][0]
|
||||||
|
assert ing["known"] is True and ing["product"] == "Kondome"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vorschlag_loeschen_nur_in_eigenen_listen(login):
|
||||||
|
anna, ben = login("anna"), login("ben")
|
||||||
|
home = anna.get("/api/haushalt").json()["lists"][0]["id"]
|
||||||
|
private_liste(anna)
|
||||||
|
anna.post("/api/haushalt/items", json={"list": "einkauf", "name": "Schwangerschaftstest", "listId": home})
|
||||||
|
# Ben löscht den Vorschlag: weg aus "Zuhause", in Annas privater Liste bleibt er
|
||||||
|
assert ben.delete("/api/haushalt/produkte", params={"name": "Schwangerschaftstest"}).status_code == 200
|
||||||
|
assert "Schwangerschaftstest" not in ben.get("/api/haushalt").json()["products"]
|
||||||
|
assert "Schwangerschaftstest" in anna.get("/api/haushalt").json()["products"]
|
||||||
|
# Ein Vorschlag, den Ben nicht sieht, lässt sich auch nicht löschen
|
||||||
|
assert ben.delete("/api/haushalt/produkte", params={"name": "Schwangerschaftstest"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Artikel, API-Doku ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_artikel_nur_eigene_bilder(login):
|
||||||
|
anna = login("anna")
|
||||||
|
name = upload(anna, jpeg_mit_gps())
|
||||||
|
html = f'<p>x</p><img src="https://tracker.example/p.gif"><img src="/uploads/{name}" alt="ok">'
|
||||||
|
content = anna.post("/api/projects", json={"title": "T", "content": html}).json()["content"]
|
||||||
|
assert "tracker.example" not in content
|
||||||
|
assert f"/uploads/{name}" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_doku_nicht_oeffentlich():
|
||||||
|
client = TestClient(app)
|
||||||
|
assert client.get("/api/openapi.json").status_code == 404
|
||||||
|
assert client.get("/api/docs").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Umstellung bestehender Daten (Schritt 2) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_umstellung_bestehender_daten(conn, login):
|
||||||
|
anna = login("anna")
|
||||||
|
home = anna.get("/api/haushalt").json()["lists"][0]["id"]
|
||||||
|
with conn:
|
||||||
|
# Stand vor Schritt 2: Katalog ohne Listen-Zuordnung, Artikel mit fremdem Bild, Foto mit GPS
|
||||||
|
conn.execute("INSERT INTO shopping_lists (id, name, everyone, position, created_at, created_by) VALUES (99, 'P', 0, 2, 'x', ?)", (anna.user["id"],))
|
||||||
|
for key, name in (("geheim", "Geheim"), ("alt", "Alt")):
|
||||||
|
conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 3, 'x')", (key, name))
|
||||||
|
conn.execute("INSERT INTO household_items (key, name, list, list_id, quantity, unit, updated_at) VALUES ('geheim', 'Geheim', 'einkauf', 99, 1, 'stk', 'x')")
|
||||||
|
conn.execute("DELETE FROM list_products")
|
||||||
|
conn.execute("""INSERT INTO projects (id, title, status, content, created_at, updated_at)
|
||||||
|
VALUES ('a1', 'T', 'aktiv', '<p>x</p><img src="https://tracker.example/p.gif">', 'x', 'x')""")
|
||||||
|
conn.execute("PRAGMA user_version = 1")
|
||||||
|
foto = DATA_DIR / "uploads" / ("ab" * 12 + ".jpg")
|
||||||
|
foto.write_bytes(jpeg_mit_gps())
|
||||||
|
db.init()
|
||||||
|
rows = {tuple(r) for r in conn.execute("SELECT list_id, key FROM list_products")}
|
||||||
|
assert rows == {(99, "geheim"), (home, "alt")}
|
||||||
|
assert "tracker" not in conn.execute("SELECT content FROM projects WHERE id = 'a1'").fetchone()[0]
|
||||||
|
assert not Image.open(foto).getexif()
|
||||||
Reference in new issue
Block a user