Reisen: Wohnorte mit Datum, Anreise je Besuch, Umzüge in der Kilometer-Rechnung
- Wohnorte als Liste mit "seit"-Datum (Tabelle travel_homes, ersetzt den einzelnen Wohnort; Migration Schritt 5 übernimmt ihn): Reisen starten beim damals gültigen Wohnort, Wechsel zählen als Umzug, Tage am Wohnort nicht als Reisetage - "In dieser Zeit hier gewohnt" im Besuch: legt Wohnort ab Beginn und den vorherigen wieder ab Ende + 1 Tag an - Anreise je Besuch (automatisch / von zuhause / vom vorherigen Ort): Rundreisen auch mit mehreren Tagen zwischen den Orten - Fix: Besuche bleiben bearbeitbar, auch wenn eine Fotodatei fehlt; Speicherfehler erscheinen im Dialog statt verdeckt dahinter Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
56c4f38ef0
commit
1d7497bead
11 files changed
+343
-113
No files matched your search
+9
-4
@@ -229,17 +229,22 @@ CREATE TABLE IF NOT EXISTS travel_visits (
|
||||
date_to TEXT,
|
||||
companions TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
continued INTEGER, -- Anreise: 1 = vom vorherigen Ort weitergereist, 0 = von zuhause, NULL = automatisch
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Wohnort pro Konto: Start und Ziel jeder Reise für die Kilometer-Statistik
|
||||
CREATE TABLE IF NOT EXISTS travel_home (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- Wohnorte pro Konto mit "seit"-Datum (NULL = von Anfang an): Start und Ziel jeder Reise in der
|
||||
-- Kilometer-Statistik; ein Wechsel (auch ein längerer Auslandsaufenthalt) zählt als Umzug
|
||||
CREATE TABLE IF NOT EXISTS travel_homes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
geoname_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
country TEXT NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL
|
||||
lon REAL NOT NULL,
|
||||
since TEXT, -- JJJJ-MM-TT
|
||||
UNIQUE (user_id, since)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS travel_visit_photos (
|
||||
|
||||
@@ -256,10 +256,25 @@ def _v4_wunschliste(conn) -> None:
|
||||
_add_column(conn, "travel_cities", "wish", "INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
|
||||
# ---------- Schritt 5: Wohnorte mit Datum, Anreise je Besuch ----------
|
||||
|
||||
|
||||
def _v5_wohnorte(conn) -> None:
|
||||
"""Einzelner Wohnort (Zwischenstand) → Liste von Wohnorten mit "seit"-Datum; Besuche bekommen die Anreise."""
|
||||
if conn.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'travel_home'").fetchone():
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO travel_homes (user_id, geoname_id, name, country, lat, lon, since)
|
||||
SELECT user_id, geoname_id, name, country, lat, lon, NULL FROM travel_home"""
|
||||
)
|
||||
conn.execute("DROP TABLE travel_home")
|
||||
_add_column(conn, "travel_visits", "continued", "INTEGER")
|
||||
|
||||
|
||||
# Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen.
|
||||
STEPS = [
|
||||
_v1_altbestand,
|
||||
_v2_datenschutz,
|
||||
_v3_kategorien,
|
||||
_v4_wunschliste,
|
||||
_v5_wohnorte,
|
||||
]
|
||||
+82
-28
@@ -82,6 +82,7 @@ def _visits(conn, user_id: int) -> list:
|
||||
{
|
||||
"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"], []),
|
||||
"continued": None if v["continued"] is None else bool(v["continued"]),
|
||||
}
|
||||
for v in rows
|
||||
]
|
||||
@@ -90,11 +91,17 @@ def _visits(conn, user_id: int) -> list:
|
||||
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()
|
||||
home = conn.execute("SELECT * FROM travel_home WHERE user_id = ?", (user_id,)).fetchone()
|
||||
homes = conn.execute(
|
||||
"SELECT * FROM travel_homes WHERE user_id = ? ORDER BY since IS NOT NULL, since", (user_id,)
|
||||
).fetchall()
|
||||
return {
|
||||
"cities": [_city(r) for r in cities],
|
||||
"countries": [r["code"] for r in countries],
|
||||
"home": {k: home[k] for k in ("name", "country", "lat", "lon")} | {"geonameId": home["geoname_id"]} if home else None,
|
||||
"homes": [
|
||||
{"id": h["id"], "geonameId": h["geoname_id"], "name": h["name"], "country": h["country"],
|
||||
"lat": h["lat"], "lon": h["lon"], "since": h["since"]}
|
||||
for h in homes
|
||||
],
|
||||
"visits": _visits(conn, user_id),
|
||||
"others": _others(conn, user_id),
|
||||
}
|
||||
@@ -180,36 +187,77 @@ def update_city(city_id: int, data: dict = Body(...), user: dict = Depends(curre
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
# ---------- Wohnort ----------
|
||||
# ---------- Wohnorte (mit "seit"-Datum) ----------
|
||||
|
||||
MAX_HOMES = 50
|
||||
|
||||
|
||||
@router.put("/api/reisen/wohnort")
|
||||
def set_home(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Wohnort festlegen (Ergebnis der Städtesuche); nur für die eigene Kilometer-Statistik."""
|
||||
def _add_home(conn, user_id: int, city: dict, since) -> None:
|
||||
"""Wohnort ab Datum eintragen; gleiches Datum wird ersetzt."""
|
||||
conn.execute("DELETE FROM travel_homes WHERE user_id = ? AND since IS ?", (user_id, since))
|
||||
if conn.execute("SELECT COUNT(*) FROM travel_homes WHERE user_id = ?", (user_id,)).fetchone()[0] >= MAX_HOMES:
|
||||
raise ValidationError(f"Maximal {MAX_HOMES} Wohnorte.")
|
||||
conn.execute(
|
||||
"INSERT INTO travel_homes (user_id, geoname_id, name, country, lat, lon, since) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(user_id, city["geoname_id"], city["name"], city["country"], city["lat"], city["lon"], since),
|
||||
)
|
||||
|
||||
|
||||
def _home_at(conn, user_id: int, day: str):
|
||||
"""Wohnort, der an diesem Tag galt (der jüngste mit since <= Tag; ohne Datum = von Anfang an)."""
|
||||
return conn.execute(
|
||||
"""SELECT * FROM travel_homes WHERE user_id = ? AND (since IS NULL OR since <= ?)
|
||||
ORDER BY since IS NOT NULL DESC, since DESC LIMIT 1""",
|
||||
(user_id, day),
|
||||
).fetchone()
|
||||
|
||||
|
||||
@router.post("/api/reisen/wohnorte", status_code=201)
|
||||
def add_home(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Wohnort festlegen ({Städtesuche-Ergebnis, since}); since leer = von Anfang an. Gleiches Datum ersetzt."""
|
||||
geoname_id = data.get("geonameId")
|
||||
if isinstance(geoname_id, bool) or not isinstance(geoname_id, int) or geoname_id <= 0:
|
||||
raise ValidationError("Ungültige Stadt.")
|
||||
country = data.get("country")
|
||||
if not isinstance(country, str) or not re.fullmatch(r"[A-Z]{2}", country):
|
||||
raise ValidationError("Ungültiges Land.")
|
||||
values = (
|
||||
user["id"], geoname_id, clean_text(data.get("name"), "Name", 100), country,
|
||||
_number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180),
|
||||
)
|
||||
city = {
|
||||
"geoname_id": geoname_id, "name": clean_text(data.get("name"), "Name", 100), "country": country,
|
||||
"lat": _number(data.get("lat"), "Breitengrad", -90, 90), "lon": _number(data.get("lon"), "Längengrad", -180, 180),
|
||||
}
|
||||
since = _date(data.get("since"), "Seit", False)
|
||||
with transaction() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO travel_home (user_id, geoname_id, name, country, lat, lon) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id) DO UPDATE SET geoname_id = excluded.geoname_id, name = excluded.name,
|
||||
country = excluded.country, lat = excluded.lat, lon = excluded.lon""",
|
||||
values,
|
||||
)
|
||||
_add_home(conn, user["id"], city, since)
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
@router.delete("/api/reisen/wohnort")
|
||||
def delete_home(user: dict = Depends(current_user)):
|
||||
@router.delete("/api/reisen/wohnorte/{home_id}")
|
||||
def delete_home(home_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
conn.execute("DELETE FROM travel_home WHERE user_id = ?", (user["id"],))
|
||||
cur = conn.execute("DELETE FROM travel_homes WHERE id = ? AND user_id = ?", (home_id, user["id"]))
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Wohnort nicht gefunden.")
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
@router.post("/api/reisen/besuche/{visit_id}/gewohnt")
|
||||
def lived_there(visit_id: int, user: dict = Depends(current_user)):
|
||||
"""Während dieses Besuchs hier gewohnt: Wohnort ab "Von", danach wieder der vorherige Wohnort ab "Bis" + 1 Tag."""
|
||||
with transaction() as conn:
|
||||
visit = _own_visit(conn, visit_id, user)
|
||||
if not visit["date_to"]:
|
||||
raise ValidationError("Für einen Aufenthalt bitte erst „Bis“ eintragen.")
|
||||
city = _own_city(conn, visit["city_id"], user)
|
||||
before = _home_at(conn, user["id"], (datetime.date.fromisoformat(visit["date_from"]) - datetime.timedelta(days=1)).isoformat())
|
||||
after = (datetime.date.fromisoformat(visit["date_to"]) + datetime.timedelta(days=1)).isoformat()
|
||||
# Spätere Wechsel innerhalb des Aufenthalts wären widersprüchlich
|
||||
conn.execute(
|
||||
"DELETE FROM travel_homes WHERE user_id = ? AND since > ? AND since < ?", (user["id"], visit["date_from"], after)
|
||||
)
|
||||
later = conn.execute("SELECT 1 FROM travel_homes WHERE user_id = ? AND since = ?", (user["id"], after)).fetchone()
|
||||
_add_home(conn, user["id"], dict(city), visit["date_from"])
|
||||
if before and not later and before["geoname_id"] != city["geoname_id"]:
|
||||
_add_home(conn, user["id"], dict(before), after)
|
||||
return _state(conn, user["id"])
|
||||
|
||||
|
||||
@@ -239,7 +287,8 @@ def _date(value, field: str, required: bool):
|
||||
return value
|
||||
|
||||
|
||||
def _visit_fields(data: dict) -> tuple:
|
||||
def _visit_fields(data: dict, known_photos=()) -> tuple:
|
||||
"""known_photos: schon gespeicherte Fotos des Besuchs – fehlt deren Datei, bleibt der Besuch trotzdem bearbeitbar."""
|
||||
date_from = _date(data.get("dateFrom"), "Von", True)
|
||||
date_to = _date(data.get("dateTo"), "Bis", False)
|
||||
if date_to and date_to < date_from:
|
||||
@@ -250,9 +299,12 @@ def _visit_fields(data: dict) -> tuple:
|
||||
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):
|
||||
if any(p not in known_photos and 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
|
||||
continued = data.get("continued")
|
||||
if continued is not None and not isinstance(continued, bool):
|
||||
raise ValidationError("Ungültige Anreise.")
|
||||
return date_from, date_to, companions, notes, photos, continued
|
||||
|
||||
|
||||
def _save_photos(conn, visit_id: int, photos: list) -> list:
|
||||
@@ -279,7 +331,7 @@ def _own_visit(conn, visit_id: int, user: dict):
|
||||
@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)
|
||||
date_from, date_to, companions, notes, photos, continued = _visit_fields(data)
|
||||
with transaction() as conn:
|
||||
_own_city(conn, city_id, user)
|
||||
if conn.execute(
|
||||
@@ -287,8 +339,9 @@ def add_visit(city_id: int, data: dict = Body(...), user: dict = Depends(current
|
||||
).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()),
|
||||
"""INSERT INTO travel_visits (city_id, date_from, date_to, companions, notes, continued, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(city_id, date_from, date_to, companions, notes, None if continued is None else int(continued), now_iso()),
|
||||
).lastrowid
|
||||
_save_photos(conn, visit_id, photos)
|
||||
conn.execute("UPDATE travel_cities SET wish = 0 WHERE id = ?", (city_id,))
|
||||
@@ -297,12 +350,13 @@ def add_visit(city_id: int, data: dict = Body(...), user: dict = Depends(current
|
||||
|
||||
@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)
|
||||
known = {r["file"] for r in conn.execute("SELECT file FROM travel_visit_photos WHERE visit_id = ?", (visit_id,))}
|
||||
date_from, date_to, companions, notes, photos, continued = _visit_fields(data, known)
|
||||
conn.execute(
|
||||
"UPDATE travel_visits SET date_from = ?, date_to = ?, companions = ?, notes = ? WHERE id = ?",
|
||||
(date_from, date_to, companions, notes, visit_id),
|
||||
"UPDATE travel_visits SET date_from = ?, date_to = ?, companions = ?, notes = ?, continued = ? WHERE id = ?",
|
||||
(date_from, date_to, companions, notes, None if continued is None else int(continued), visit_id),
|
||||
)
|
||||
remove_unused(conn, _save_photos(conn, visit_id, photos))
|
||||
return _state(conn, user["id"])
|
||||
|
||||
Reference in new issue
Block a user