- 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>
597 lines
27 KiB
Python
597 lines
27 KiB
Python
"""Reise-Karte pro Konto: besuchte Städte und Länder sowie kurze Infos dazu.
|
||
|
||
Die Städte der anderen Konten werden mitgeliefert und lassen sich auf der Karte einblenden (nur lesen).
|
||
|
||
Städte werden über die Geocoding-API von Open-Meteo gesucht (Daten: GeoNames, CC BY 4.0).
|
||
Infotexte kommen aus der deutschen Wikipedia, Einwohnerzahlen von Ländern aus Wikidata.
|
||
Alle Abrufe laufen über den Server und werden zwischengespeichert; der Browser fragt keine Fremdserver.
|
||
"""
|
||
import asyncio
|
||
import datetime
|
||
import json
|
||
import logging
|
||
import math
|
||
import re
|
||
import time
|
||
from urllib.parse import quote
|
||
|
||
import httpx
|
||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||
|
||
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()
|
||
|
||
HEADERS = {"User-Agent": "P42-Dashboard/1.0 (privates Heim-Dashboard)"}
|
||
# Wikimedia sperrt Clients ohne Kontaktangabe (https://w.wiki/4wJS)
|
||
WIKI_HEADERS = {"User-Agent": f"P42-Dashboard/1.0 (privates Heim-Dashboard; {WIKIPEDIA_CONTACT}) python-httpx"}
|
||
TIMEOUT = 8
|
||
MAX_CITIES = 2000
|
||
CACHE_OK = 30 * 24 * 3600 # Infos ändern sich selten
|
||
CACHE_MISS = 24 * 3600 # nichts gefunden: morgen noch mal versuchen
|
||
COUNTRY_CODE = re.compile(r"[A-Z]{2}|X-[A-Za-z. ]{1,40}") # X-…: Gebiete ohne ISO-Code auf der Karte
|
||
|
||
CAPITALS_SECONDS = 6 * 3600
|
||
|
||
_search_cache: dict[str, list] = {}
|
||
log = logging.getLogger(__name__)
|
||
|
||
|
||
def _city(row) -> dict:
|
||
return {
|
||
"id": row["id"], "geonameId": row["geoname_id"], "name": row["name"], "admin1": row["admin1"],
|
||
"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"]),
|
||
}
|
||
|
||
|
||
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 != ? AND c.wish = 0 ORDER BY u.display_name COLLATE NOCASE, u.id, c.name""",
|
||
(user_id,),
|
||
).fetchall()
|
||
people = {}
|
||
for r in rows:
|
||
person = people.setdefault(r["user_id"], {"id": r["user_id"], "name": r["display_name"], "cities": []})
|
||
person["cities"].append(_city(r))
|
||
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"], []),
|
||
"continued": None if v["continued"] is None else bool(v["continued"]),
|
||
}
|
||
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()
|
||
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],
|
||
"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),
|
||
}
|
||
|
||
|
||
# ---------- Verwaltung ----------
|
||
|
||
|
||
@router.get("/api/reisen")
|
||
def get_travel(user: dict = Depends(current_user)):
|
||
with transaction() as conn:
|
||
return _state(conn, user["id"])
|
||
|
||
|
||
def _number(value, field: str, low: float, high: float) -> float:
|
||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high:
|
||
raise ValidationError(f"Ungültiger Wert für {field}.")
|
||
return float(value)
|
||
|
||
|
||
@router.post("/api/reisen/staedte", status_code=201)
|
||
def add_city(data: dict = Body(...), user: dict = Depends(current_user)):
|
||
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.")
|
||
population = data.get("population")
|
||
if population is not None and (isinstance(population, bool) or not isinstance(population, int) or population < 0):
|
||
population = None
|
||
elevation = data.get("elevation")
|
||
if elevation is not None and (isinstance(elevation, bool) or not isinstance(elevation, (int, float)) or not -500 <= elevation <= 9000):
|
||
elevation = None
|
||
timezone = data.get("timezone")
|
||
if not isinstance(timezone, str) or not re.fullmatch(r"[A-Za-z_]+(?:/[A-Za-z0-9_+-]+){0,2}", timezone):
|
||
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, 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, wish, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
|
||
values,
|
||
)
|
||
return _state(conn, user["id"])
|
||
|
||
|
||
@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"])
|
||
|
||
|
||
# ---------- Wohnorte (mit "seit"-Datum) ----------
|
||
|
||
MAX_HOMES = 50
|
||
|
||
|
||
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.")
|
||
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:
|
||
_add_home(conn, user["id"], city, since)
|
||
return _state(conn, user["id"])
|
||
|
||
|
||
@router.delete("/api/reisen/wohnorte/{home_id}")
|
||
def delete_home(home_id: int, user: dict = Depends(current_user)):
|
||
with transaction() as conn:
|
||
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"])
|
||
|
||
|
||
# ---------- 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, 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:
|
||
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(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.")
|
||
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:
|
||
"""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, continued = _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, 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,))
|
||
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)):
|
||
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 = ?, 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"])
|
||
|
||
|
||
@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"])
|
||
|
||
|
||
def _country_code(code: str) -> str:
|
||
if not COUNTRY_CODE.fullmatch(code):
|
||
raise ValidationError("Ungültiges Land.")
|
||
return code
|
||
|
||
|
||
@router.put("/api/reisen/laender/{code}")
|
||
def mark_country(code: str, user: dict = Depends(current_user)):
|
||
with transaction() as conn:
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO travel_countries (user_id, code) VALUES (?, ?)", (user["id"], _country_code(code))
|
||
)
|
||
return _state(conn, user["id"])
|
||
|
||
|
||
@router.delete("/api/reisen/laender/{code}")
|
||
def unmark_country(code: str, user: dict = Depends(current_user)):
|
||
with transaction() as conn:
|
||
conn.execute("DELETE FROM travel_countries WHERE user_id = ? AND code = ?", (user["id"], _country_code(code)))
|
||
return _state(conn, user["id"])
|
||
|
||
|
||
# ---------- Städtesuche ----------
|
||
|
||
|
||
@router.get("/api/reisen/suche")
|
||
async def search_city(q: str = "", _: dict = Depends(current_user)):
|
||
q = re.sub(r"\s+", " ", q).strip()
|
||
if len(q) < 2 or len(q) > 80:
|
||
return []
|
||
key = q.casefold()
|
||
if key in _search_cache:
|
||
return _search_cache[key]
|
||
try:
|
||
async with httpx.AsyncClient(timeout=TIMEOUT, headers=HEADERS) as client:
|
||
res = await client.get(
|
||
"https://geocoding-api.open-meteo.com/v1/search",
|
||
params={"name": q, "count": 8, "language": "de", "format": "json"},
|
||
)
|
||
res.raise_for_status()
|
||
except httpx.HTTPError:
|
||
raise HTTPException(502, "Die Städtesuche ist gerade nicht erreichbar.")
|
||
results = [
|
||
{
|
||
"geonameId": r["id"], "name": r["name"], "admin1": r.get("admin1") or "", "countryName": r.get("country") or "",
|
||
"country": r["country_code"], "lat": r["latitude"], "lon": r["longitude"], "population": r.get("population"),
|
||
"elevation": r.get("elevation"), "timezone": r.get("timezone"),
|
||
"capital": r.get("feature_code") == "PPLC",
|
||
}
|
||
for r in res.json().get("results", [])
|
||
if r.get("country_code") and r.get("id")
|
||
]
|
||
if len(_search_cache) > 300:
|
||
_search_cache.clear()
|
||
_search_cache[key] = results
|
||
return results
|
||
|
||
|
||
# ---------- Hauptstadt-Kennzeichen für ältere Einträge nachtragen ----------
|
||
|
||
|
||
async def fill_capitals() -> None:
|
||
"""Städte ohne Hauptstadt-Angabe bei Open-Meteo nachschlagen (GeoNames-Merkmal PPLC)."""
|
||
with transaction() as conn:
|
||
ids = [r[0] for r in conn.execute("SELECT DISTINCT geoname_id FROM travel_cities WHERE capital IS NULL")]
|
||
if not ids:
|
||
return
|
||
async with httpx.AsyncClient(timeout=TIMEOUT, headers=HEADERS) as client:
|
||
for geoname_id in ids:
|
||
try:
|
||
res = await client.get("https://geocoding-api.open-meteo.com/v1/get", params={"id": geoname_id})
|
||
res.raise_for_status()
|
||
capital = int(res.json().get("feature_code") == "PPLC")
|
||
except (httpx.HTTPError, ValueError):
|
||
continue # beim nächsten Durchlauf erneut versuchen
|
||
with transaction() as conn:
|
||
conn.execute("UPDATE travel_cities SET capital = ? WHERE geoname_id = ? AND capital IS NULL", (capital, geoname_id))
|
||
await asyncio.sleep(0.2) # Open-Meteo schonen
|
||
|
||
|
||
async def capitals_loop() -> None:
|
||
while True:
|
||
try:
|
||
await fill_capitals()
|
||
except Exception: # nie die Schleife beenden
|
||
log.exception("Hauptstädte nachtragen fehlgeschlagen")
|
||
await asyncio.sleep(CAPITALS_SECONDS)
|
||
|
||
|
||
# ---------- Infos aus Wikipedia / Wikidata ----------
|
||
|
||
|
||
def _distance_km(a, b) -> float:
|
||
lat1, lon1, lat2, lon2 = map(math.radians, (a[0], a[1], b[0], b[1]))
|
||
h = math.sin((lat2 - lat1) / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2) ** 2
|
||
return 6371 * 2 * math.asin(math.sqrt(h))
|
||
|
||
|
||
async def _summary(client, title: str):
|
||
res = await client.get(f"https://de.wikipedia.org/api/rest_v1/page/summary/{quote(title.replace(' ', '_'), safe='')}")
|
||
return res.json() if res.status_code == 200 else None
|
||
|
||
|
||
async def _wikipedia(client, title: str, near=None, max_km=60):
|
||
"""Artikel zum Titel; bei Begriffsklärung oder falschem Ort über die Suche den Artikel in der Nähe finden."""
|
||
|
||
def fits(page, exact):
|
||
if not page or page.get("type") != "standard":
|
||
return False
|
||
if near is None:
|
||
return True
|
||
coords = page.get("coordinates")
|
||
if not coords:
|
||
return exact # exakter Titel ohne Koordinaten: meist trotzdem richtig
|
||
return _distance_km(near, (coords["lat"], coords["lon"])) <= max_km
|
||
|
||
page = await _summary(client, title)
|
||
if fits(page, True):
|
||
return page
|
||
res = await client.get(
|
||
"https://de.wikipedia.org/w/api.php",
|
||
params={"action": "query", "list": "search", "srsearch": title, "srlimit": 8, "format": "json"},
|
||
)
|
||
for hit in res.json().get("query", {}).get("search", []):
|
||
page = await _summary(client, hit["title"])
|
||
if fits(page, False):
|
||
return page
|
||
return None
|
||
|
||
|
||
async def _population(client, item: str):
|
||
"""Aktuelle Einwohnerzahl aus Wikidata (bevorzugter Wert, sonst der neueste)."""
|
||
res = await client.get(
|
||
"https://www.wikidata.org/w/api.php",
|
||
params={"action": "wbgetclaims", "entity": item, "property": "P1082", "format": "json"},
|
||
)
|
||
claims = res.json().get("claims", {}).get("P1082", [])
|
||
|
||
def year(c):
|
||
t = c.get("qualifiers", {}).get("P585", [{}])[0].get("datavalue", {}).get("value", {}).get("time", "")
|
||
return t[1:5] if t else ""
|
||
|
||
best = [c for c in claims if c.get("rank") == "preferred"] or claims
|
||
best = [c for c in best if c["mainsnak"].get("datavalue")]
|
||
if not best:
|
||
return None, None
|
||
claim = max(best, key=year)
|
||
return int(float(claim["mainsnak"]["datavalue"]["value"]["amount"])), year(claim) or None
|
||
|
||
|
||
async def _capital(client, item: str):
|
||
"""Aktuelle Hauptstadt(e) mit deutschem Namen aus Wikidata (ohne Enddatum)."""
|
||
res = await client.get(
|
||
"https://www.wikidata.org/w/api.php",
|
||
params={"action": "wbgetclaims", "entity": item, "property": "P36", "format": "json"},
|
||
)
|
||
claims = [
|
||
c for c in res.json().get("claims", {}).get("P36", [])
|
||
if c["mainsnak"].get("datavalue") and "P582" not in c.get("qualifiers", {}) and c.get("rank") != "deprecated"
|
||
]
|
||
ids = [c["mainsnak"]["datavalue"]["value"]["id"] for c in claims][:3]
|
||
if not ids:
|
||
return None
|
||
res = await client.get(
|
||
"https://www.wikidata.org/w/api.php",
|
||
params={"action": "wbgetentities", "ids": "|".join(ids), "props": "labels", "languages": "de|en", "format": "json"},
|
||
)
|
||
entities = res.json().get("entities", {})
|
||
names = [
|
||
(entities.get(i, {}).get("labels", {}).get("de") or entities.get(i, {}).get("labels", {}).get("en") or {}).get("value")
|
||
for i in ids
|
||
]
|
||
return ", ".join(n for n in names if n) or None
|
||
|
||
|
||
def _cache_get(conn, key):
|
||
row = conn.execute("SELECT value, fetched_at FROM info_cache WHERE key = ?", (key,)).fetchone()
|
||
if not row:
|
||
return None
|
||
value = json.loads(row["value"])
|
||
ttl = CACHE_OK if value.get("extract") else CACHE_MISS
|
||
return value if time.time() - row["fetched_at"] < ttl else None
|
||
|
||
|
||
@router.get("/api/reisen/info")
|
||
async def get_info(kind: str, name: str, code: str = "", lat: float | None = None, lon: float | None = None,
|
||
_: dict = Depends(current_user)):
|
||
"""Kurzinfo zu einem Land (kind=land, code=ISO) oder einer Stadt (kind=stadt, code=GeoNames-ID, lat/lon)."""
|
||
if kind not in ("land", "stadt") or not 1 <= len(name) <= 100 or len(code) > 45:
|
||
raise ValidationError("Ungültige Anfrage.")
|
||
if not WIKIPEDIA_CONTACT:
|
||
return {"extract": None, "url": None, "title": None, "population": None, "populationYear": None, "disabled": True}
|
||
key = f"{kind}:{code or name}"
|
||
with transaction() as conn:
|
||
cached = _cache_get(conn, key)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
near = (lat, lon) if kind == "stadt" and lat is not None and lon is not None else None
|
||
try:
|
||
async with httpx.AsyncClient(timeout=TIMEOUT, headers=WIKI_HEADERS, follow_redirects=True) as client:
|
||
page = await _wikipedia(client, name, near)
|
||
info = {"extract": None, "url": None, "title": None, "population": None, "populationYear": None, "capital": None}
|
||
if page:
|
||
info.update(
|
||
extract=page.get("extract"),
|
||
url=page.get("content_urls", {}).get("desktop", {}).get("page"),
|
||
title=page.get("title"),
|
||
)
|
||
if kind == "land" and page.get("wikibase_item"):
|
||
info["population"], info["populationYear"] = await _population(client, page["wikibase_item"])
|
||
info["capital"] = await _capital(client, page["wikibase_item"])
|
||
except (httpx.HTTPError, ValueError):
|
||
# Nicht zwischenspeichern: beim nächsten Klick erneut versuchen
|
||
return {"extract": None, "url": None, "title": None, "population": None, "populationYear": None, "offline": True}
|
||
|
||
with transaction() as conn:
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO info_cache (key, value, fetched_at) VALUES (?, ?, ?)",
|
||
(key, json.dumps(info, ensure_ascii=False), time.time()),
|
||
)
|
||
return info
|