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:
guyg1474andClaude Opus 5.5 committed 2026-10-15 19:25:07 +02:00
1 parent 9835f5e69e
commit bbf740603e
16 files changed
+695 -32

No files matched your search

+155 -4
View File
@@ -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"])