Reisen: Wunschliste, Besuche mit Fotos, Zeitleiste und Statistik
- Wunschliste (Spalte travel_cities.wish): eigene Punktform auf der Karte, zählt nicht als besucht, für andere Konten unsichtbar; "War ich da ✓" - Besuche je Stadt (Tabellen travel_visits, travel_visit_photos): Zeitraum, Begleitung, Notizen, Fotos (Upload ohne Metadaten); nur für das eigene Konto; ein Besuch macht aus einem Wunsch einen Besuch - Fotos von Besuchen zählen beim Aufräumen verwaister Bilder als benutzt; beim Entfernen eines Fotos, Besuchs oder einer Stadt werden die Dateien gelöscht - Zeitleiste "Wann war ich wo?" nach Jahren, Fotos mit Lightbox - Statistik: Länder (Welt, je Kontinent), Landfläche, äußerste Orte, Kilometer entlang der Besuche - Ansichten unter der Karte als Tabs: Besuchte Orte, Zeitleiste, Statistik, Wunschliste - Migration Schritt 4, Tests für Wunschliste, Besuche, Fotos und Aufräumen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9835f5e69e
commit
bbf740603e
16 files changed
+695
-32
No files matched your search
@@ -198,10 +198,29 @@ CREATE TABLE IF NOT EXISTS travel_cities (
|
||||
elevation REAL,
|
||||
timezone TEXT,
|
||||
capital INTEGER, -- Hauptstadt (GeoNames PPLC); NULL = noch nicht abgefragt
|
||||
wish INTEGER NOT NULL DEFAULT 0, -- 1 = Wunschliste (noch nicht besucht)
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (user_id, geoname_id)
|
||||
);
|
||||
|
||||
-- Besuche einer Stadt (nur für das eigene Konto sichtbar): Zeitraum, Begleitung, Notizen, Fotos
|
||||
CREATE TABLE IF NOT EXISTS travel_visits (
|
||||
id INTEGER PRIMARY KEY,
|
||||
city_id INTEGER NOT NULL REFERENCES travel_cities(id) ON DELETE CASCADE,
|
||||
date_from TEXT NOT NULL, -- JJJJ-MM-TT
|
||||
date_to TEXT,
|
||||
companions TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS travel_visit_photos (
|
||||
visit_id INTEGER NOT NULL REFERENCES travel_visits(id) ON DELETE CASCADE,
|
||||
file TEXT NOT NULL, -- Datei in data/uploads (ohne Metadaten gespeichert)
|
||||
position INTEGER NOT NULL,
|
||||
PRIMARY KEY (visit_id, file)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS travel_countries (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code TEXT NOT NULL,
|
||||
|
||||
@@ -249,9 +249,17 @@ def _v3_kategorien(conn) -> None:
|
||||
conn.execute("UPDATE products SET category = ? WHERE key = ?", (guess(row["name"]), row["key"]))
|
||||
|
||||
|
||||
# ---------- Schritt 4: Reise-Wunschliste ----------
|
||||
|
||||
|
||||
def _v4_wunschliste(conn) -> None:
|
||||
_add_column(conn, "travel_cities", "wish", "INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
|
||||
# Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen.
|
||||
STEPS = [
|
||||
_v1_altbestand,
|
||||
_v2_datenschutz,
|
||||
_v3_kategorien,
|
||||
_v4_wunschliste,
|
||||
]
|
||||
@@ -72,7 +72,8 @@ def _detect_image_type(data: bytes) -> str | None:
|
||||
|
||||
|
||||
def _referenced_images(conn) -> set[str]:
|
||||
used = set()
|
||||
"""Alle Bilder, die noch gebraucht werden: Artikel (Titelbild, Text) und Fotos von Reise-Besuchen."""
|
||||
used = {r["file"] for r in conn.execute("SELECT file FROM travel_visit_photos")}
|
||||
for row in conn.execute("SELECT cover, content FROM projects"):
|
||||
if row["cover"]:
|
||||
used.add(row["cover"])
|
||||
@@ -80,6 +81,11 @@ def _referenced_images(conn) -> set[str]:
|
||||
return used
|
||||
|
||||
|
||||
def remove_unused(conn, names) -> None:
|
||||
"""Bilder löschen, die nirgends mehr verwendet werden (z. B. nach dem Löschen eines Besuchs)."""
|
||||
_remove_unused(conn, names)
|
||||
|
||||
|
||||
def _remove_unused(conn, names) -> None:
|
||||
used = _referenced_images(conn)
|
||||
for n in names:
|
||||
|
||||
+155
-4
@@ -7,6 +7,7 @@ Infotexte kommen aus der deutschen Wikipedia, Einwohnerzahlen von Ländern aus W
|
||||
Alle Abrufe laufen über den Server und werden zwischengespeichert; der Browser fragt keine Fremdserver.
|
||||
"""
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
@@ -21,6 +22,7 @@ from .auth import current_user
|
||||
from .config import WIKIPEDIA_CONTACT
|
||||
from .common import ValidationError, clean_text, now_iso
|
||||
from .db import transaction
|
||||
from .projects import UPLOAD_DIR, UPLOAD_NAME, remove_unused
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -45,6 +47,7 @@ def _city(row) -> dict:
|
||||
"country": row["country"], "lat": row["lat"], "lon": row["lon"], "population": row["population"],
|
||||
"elevation": row["elevation"], "timezone": row["timezone"], "createdAt": row["created_at"],
|
||||
"capital": None if row["capital"] is None else bool(row["capital"]),
|
||||
"wish": bool(row["wish"]),
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +55,7 @@ def _others(conn, user_id: int) -> list:
|
||||
"""Städte der anderen Konten (nur zum Anzeigen auf der Karte), gruppiert nach Konto."""
|
||||
rows = conn.execute(
|
||||
"""SELECT c.*, u.display_name FROM travel_cities c JOIN users u ON u.id = c.user_id
|
||||
WHERE c.user_id != ? ORDER BY u.display_name COLLATE NOCASE, u.id, c.name""",
|
||||
WHERE c.user_id != ? AND c.wish = 0 ORDER BY u.display_name COLLATE NOCASE, u.id, c.name""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
people = {}
|
||||
@@ -62,12 +65,35 @@ def _others(conn, user_id: int) -> list:
|
||||
return list(people.values())
|
||||
|
||||
|
||||
def _visits(conn, user_id: int) -> list:
|
||||
rows = conn.execute(
|
||||
"""SELECT v.* FROM travel_visits v JOIN travel_cities c ON c.id = v.city_id
|
||||
WHERE c.user_id = ? ORDER BY v.date_from DESC, v.id DESC""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
photos = {}
|
||||
for p in conn.execute(
|
||||
"""SELECT p.* FROM travel_visit_photos p JOIN travel_visits v ON v.id = p.visit_id
|
||||
JOIN travel_cities c ON c.id = v.city_id WHERE c.user_id = ? ORDER BY p.position""",
|
||||
(user_id,),
|
||||
):
|
||||
photos.setdefault(p["visit_id"], []).append(p["file"])
|
||||
return [
|
||||
{
|
||||
"id": v["id"], "cityId": v["city_id"], "dateFrom": v["date_from"], "dateTo": v["date_to"],
|
||||
"companions": v["companions"], "notes": v["notes"], "photos": photos.get(v["id"], []),
|
||||
}
|
||||
for v in rows
|
||||
]
|
||||
|
||||
|
||||
def _state(conn, user_id: int) -> dict:
|
||||
cities = conn.execute("SELECT * FROM travel_cities WHERE user_id = ? ORDER BY name", (user_id,)).fetchall()
|
||||
countries = conn.execute("SELECT code FROM travel_countries WHERE user_id = ? ORDER BY code", (user_id,)).fetchall()
|
||||
return {
|
||||
"cities": [_city(r) for r in cities],
|
||||
"countries": [r["code"] for r in countries],
|
||||
"visits": _visits(conn, user_id),
|
||||
"others": _others(conn, user_id),
|
||||
}
|
||||
|
||||
@@ -106,18 +132,19 @@ def add_city(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
timezone = None
|
||||
# Hauptstadt kommt aus der Suche; fehlt die Angabe, trägt fill_capitals() sie nach
|
||||
capital = int(data["capital"]) if isinstance(data.get("capital"), bool) else None
|
||||
wish = data.get("wish") is True
|
||||
values = (
|
||||
user["id"], geoname_id, clean_text(data.get("name"), "Name", 100), clean_text(data.get("admin1"), "Region", 100, required=False),
|
||||
country, _number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180),
|
||||
population, elevation, timezone, capital, now_iso(),
|
||||
population, elevation, timezone, capital, int(wish), now_iso(),
|
||||
)
|
||||
with transaction() as conn:
|
||||
if conn.execute("SELECT COUNT(*) FROM travel_cities WHERE user_id = ?", (user["id"],)).fetchone()[0] >= MAX_CITIES:
|
||||
raise ValidationError(f"Maximal {MAX_CITIES} Städte.")
|
||||
conn.execute(
|
||||
"""INSERT INTO travel_cities
|
||||
(user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, capital, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
|
||||
(user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, capital, wish, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
|
||||
values,
|
||||
)
|
||||
return _state(conn, user["id"])
|
||||
@@ -126,9 +153,133 @@ def add_city(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
@router.delete("/api/reisen/staedte/{city_id}")
|
||||
def delete_city(city_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
photos = [r["file"] for r in conn.execute(
|
||||
"""SELECT p.file FROM travel_visit_photos p JOIN travel_visits v ON v.id = p.visit_id
|
||||
JOIN travel_cities c ON c.id = v.city_id WHERE c.id = ? AND c.user_id = ?""",
|
||||
(city_id, user["id"]),
|
||||
)]
|
||||
cur = conn.execute("DELETE FROM travel_cities WHERE id = ? AND user_id = ?", (city_id, user["id"]))
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Stadt nicht gefunden.")
|
||||
remove_unused(conn, photos)
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
@router.patch("/api/reisen/staedte/{city_id}")
|
||||
def update_city(city_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Wunschliste ↔ besucht ({wish: bool})."""
|
||||
if not isinstance(data.get("wish"), bool):
|
||||
raise ValidationError("Ungültige Angabe.")
|
||||
with transaction() as conn:
|
||||
_own_city(conn, city_id, user)
|
||||
if data["wish"] and conn.execute("SELECT 1 FROM travel_visits WHERE city_id = ?", (city_id,)).fetchone():
|
||||
raise ValidationError("Für diese Stadt sind schon Besuche eingetragen.")
|
||||
conn.execute("UPDATE travel_cities SET wish = ? WHERE id = ?", (int(data["wish"]), city_id))
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
# ---------- Besuche: Zeitraum, Begleitung, Notizen, Fotos ----------
|
||||
|
||||
DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
||||
MAX_PHOTOS = 30
|
||||
MAX_VISITS = 5000
|
||||
|
||||
|
||||
def _own_city(conn, city_id: int, user: dict):
|
||||
row = conn.execute("SELECT * FROM travel_cities WHERE id = ? AND user_id = ?", (city_id, user["id"])).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Stadt nicht gefunden.")
|
||||
return row
|
||||
|
||||
|
||||
def _date(value, field: str, required: bool):
|
||||
if value in (None, "") and not required:
|
||||
return None
|
||||
if not isinstance(value, str) or not DATE.fullmatch(value):
|
||||
raise ValidationError(f"{field}: bitte ein Datum angeben.")
|
||||
try:
|
||||
datetime.date.fromisoformat(value)
|
||||
except ValueError:
|
||||
raise ValidationError(f"{field}: ungültiges Datum.")
|
||||
return value
|
||||
|
||||
|
||||
def _visit_fields(data: dict) -> tuple:
|
||||
date_from = _date(data.get("dateFrom"), "Von", True)
|
||||
date_to = _date(data.get("dateTo"), "Bis", False)
|
||||
if date_to and date_to < date_from:
|
||||
raise ValidationError("„Bis“ liegt vor „Von“.")
|
||||
companions = clean_text(data.get("companions"), "Begleitung", 200, required=False)
|
||||
notes = clean_text(data.get("notes"), "Notizen", 5000, required=False, collapse=False)
|
||||
photos = data.get("photos", [])
|
||||
if not isinstance(photos, list) or len(photos) > MAX_PHOTOS or not all(isinstance(p, str) and UPLOAD_NAME.fullmatch(p) for p in photos):
|
||||
raise ValidationError(f"Ungültige Fotos (max. {MAX_PHOTOS}).")
|
||||
photos = list(dict.fromkeys(photos))
|
||||
if any(not (UPLOAD_DIR / p).exists() for p in photos):
|
||||
raise ValidationError("Ein Foto wurde nicht gefunden – bitte erneut hochladen.")
|
||||
return date_from, date_to, companions, notes, photos
|
||||
|
||||
|
||||
def _save_photos(conn, visit_id: int, photos: list) -> list:
|
||||
"""Fotos speichern; Rückgabe: Dateien, die dabei weggefallen sind."""
|
||||
old = [r["file"] for r in conn.execute("SELECT file FROM travel_visit_photos WHERE visit_id = ?", (visit_id,))]
|
||||
conn.execute("DELETE FROM travel_visit_photos WHERE visit_id = ?", (visit_id,))
|
||||
conn.executemany(
|
||||
"INSERT INTO travel_visit_photos (visit_id, file, position) VALUES (?, ?, ?)",
|
||||
[(visit_id, f, i) for i, f in enumerate(photos)],
|
||||
)
|
||||
return [f for f in old if f not in photos]
|
||||
|
||||
|
||||
def _own_visit(conn, visit_id: int, user: dict):
|
||||
row = conn.execute(
|
||||
"SELECT v.* FROM travel_visits v JOIN travel_cities c ON c.id = v.city_id WHERE v.id = ? AND c.user_id = ?",
|
||||
(visit_id, user["id"]),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Besuch nicht gefunden.")
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/api/reisen/staedte/{city_id}/besuche", status_code=201)
|
||||
def add_visit(city_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Besuch eintragen; eine Stadt von der Wunschliste gilt damit als besucht."""
|
||||
date_from, date_to, companions, notes, photos = _visit_fields(data)
|
||||
with transaction() as conn:
|
||||
_own_city(conn, city_id, user)
|
||||
if conn.execute(
|
||||
"SELECT COUNT(*) FROM travel_visits v JOIN travel_cities c ON c.id = v.city_id WHERE c.user_id = ?", (user["id"],)
|
||||
).fetchone()[0] >= MAX_VISITS:
|
||||
raise ValidationError(f"Maximal {MAX_VISITS} Besuche.")
|
||||
visit_id = conn.execute(
|
||||
"INSERT INTO travel_visits (city_id, date_from, date_to, companions, notes, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(city_id, date_from, date_to, companions, notes, now_iso()),
|
||||
).lastrowid
|
||||
_save_photos(conn, visit_id, photos)
|
||||
conn.execute("UPDATE travel_cities SET wish = 0 WHERE id = ?", (city_id,))
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
@router.put("/api/reisen/besuche/{visit_id}")
|
||||
def update_visit(visit_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
date_from, date_to, companions, notes, photos = _visit_fields(data)
|
||||
with transaction() as conn:
|
||||
_own_visit(conn, visit_id, user)
|
||||
conn.execute(
|
||||
"UPDATE travel_visits SET date_from = ?, date_to = ?, companions = ?, notes = ? WHERE id = ?",
|
||||
(date_from, date_to, companions, notes, visit_id),
|
||||
)
|
||||
remove_unused(conn, _save_photos(conn, visit_id, photos))
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
@router.delete("/api/reisen/besuche/{visit_id}")
|
||||
def delete_visit(visit_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
_own_visit(conn, visit_id, user)
|
||||
photos = [r["file"] for r in conn.execute("SELECT file FROM travel_visit_photos WHERE visit_id = ?", (visit_id,))]
|
||||
conn.execute("DELETE FROM travel_visits WHERE id = ?", (visit_id,))
|
||||
remove_unused(conn, photos)
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
|
||||
@@ -32,3 +32,83 @@ def test_land_markieren(login):
|
||||
assert anna.put("/api/reisen/laender/IS").json()["countries"] == ["IS"]
|
||||
assert anna.delete("/api/reisen/laender/IS").json()["countries"] == []
|
||||
assert anna.put("/api/reisen/laender/xx").status_code == 400
|
||||
|
||||
|
||||
# ---------- Wunschliste und Besuche ----------
|
||||
|
||||
import io # noqa: E402
|
||||
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
from app.config import DATA_DIR # noqa: E402
|
||||
|
||||
PRAG = {"geonameId": 3067696, "name": "Prag", "country": "CZ", "lat": 50.08, "lon": 14.42, "capital": True}
|
||||
|
||||
|
||||
def foto(client) -> str:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), "blue").save(buf, "JPEG")
|
||||
return client.post("/api/uploads", content=buf.getvalue()).json()["file"]
|
||||
|
||||
|
||||
def test_wunschliste(login):
|
||||
anna, ben = login("anna"), login("ben")
|
||||
data = anna.post("/api/reisen/staedte", json={**PRAG, "wish": True}).json()
|
||||
prag = data["cities"][0]
|
||||
assert prag["wish"] is True
|
||||
assert ben.get("/api/reisen").json()["others"] == [] # Wünsche sehen andere nicht
|
||||
data = anna.patch(f"/api/reisen/staedte/{prag['id']}", json={"wish": False}).json()
|
||||
assert data["cities"][0]["wish"] is False
|
||||
assert [p["name"] for p in ben.get("/api/reisen").json()["others"]] == ["Anna"]
|
||||
|
||||
|
||||
def test_besuch_mit_fotos(login):
|
||||
anna, ben = login("anna"), login("ben")
|
||||
prag = anna.post("/api/reisen/staedte", json={**PRAG, "wish": True}).json()["cities"][0]
|
||||
f1, f2 = foto(anna), foto(anna)
|
||||
data = anna.post(f"/api/reisen/staedte/{prag['id']}/besuche", json={
|
||||
"dateFrom": "2024-03-01", "dateTo": "2024-03-04", "companions": "Ben", "notes": "Karlsbrücke", "photos": [f1, f2],
|
||||
}).json()
|
||||
assert data["cities"][0]["wish"] is False # Besuch macht aus dem Wunsch einen Besuch
|
||||
visit = data["visits"][0]
|
||||
assert (visit["dateFrom"], visit["dateTo"], visit["companions"], visit["photos"]) == ("2024-03-01", "2024-03-04", "Ben", [f1, f2])
|
||||
assert ben.get("/api/reisen").json().get("visits") == [] # Besuche sind privat
|
||||
# Foto entfernt → Datei gelöscht; Besuch gelöscht → alle Fotos weg
|
||||
anna.put(f"/api/reisen/besuche/{visit['id']}", json={**visit, "photos": [f1]})
|
||||
assert not (DATA_DIR / "uploads" / f2).exists()
|
||||
anna.delete(f"/api/reisen/besuche/{visit['id']}")
|
||||
assert not (DATA_DIR / "uploads" / f1).exists()
|
||||
|
||||
|
||||
def test_stadt_loeschen_entfernt_fotos(login):
|
||||
anna = login("anna")
|
||||
prag = anna.post("/api/reisen/staedte", json=PRAG).json()["cities"][0]
|
||||
f = foto(anna)
|
||||
anna.post(f"/api/reisen/staedte/{prag['id']}/besuche", json={"dateFrom": "2023-05-01", "photos": [f]})
|
||||
anna.delete(f"/api/reisen/staedte/{prag['id']}")
|
||||
assert not (DATA_DIR / "uploads" / f).exists()
|
||||
|
||||
|
||||
def test_besuch_pruefungen(login):
|
||||
anna, ben = login("anna"), login("ben")
|
||||
prag = anna.post("/api/reisen/staedte", json=PRAG).json()["cities"][0]
|
||||
url = f"/api/reisen/staedte/{prag['id']}/besuche"
|
||||
assert anna.post(url, json={"dateFrom": "2024-13-01"}).status_code == 400
|
||||
assert anna.post(url, json={"dateFrom": "2024-05-02", "dateTo": "2024-05-01"}).status_code == 400
|
||||
assert anna.post(url, json={"dateFrom": "2024-05-02", "photos": ["../../etc/passwd"]}).status_code == 400
|
||||
assert anna.post(url, json={"dateFrom": "2024-05-02", "photos": ["9e" * 12 + ".jpg"]}).status_code == 400 # gibt es nicht
|
||||
assert ben.post(url, json={"dateFrom": "2024-05-02"}).status_code == 404 # fremde Stadt
|
||||
|
||||
|
||||
def test_reisefotos_ueberleben_aufraeumen(login):
|
||||
"""Das Aufräumen verwaister Bilder darf Reisefotos nicht löschen."""
|
||||
from app import projects
|
||||
anna = login("anna")
|
||||
prag = anna.post("/api/reisen/staedte", json=PRAG).json()["cities"][0]
|
||||
f = foto(anna)
|
||||
anna.post(f"/api/reisen/staedte/{prag['id']}/besuche", json={"dateFrom": "2023-05-01", "photos": [f]})
|
||||
import os, time
|
||||
old = time.time() - 3 * 24 * 3600
|
||||
os.utime(DATA_DIR / "uploads" / f, (old, old))
|
||||
projects._cleanup_orphans()
|
||||
assert (DATA_DIR / "uploads" / f).exists()
|
||||
Reference in new issue
Block a user