Files
p42.f-w/backend/app/reisen.py
T
guyg1474andClaude Opus 5.5 1d7497bead 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>
2026-10-15 20:40:40 +02:00

597 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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