Files
p42.f-w/backend/app/reisen.py
T
guyg1474andClaude Opus 5.5 b30acb652a Listen mit eigenem Vorrat und Einkauf, Städte anderer auf der Reise-Karte, Design "Fabrik"
Vorrat & Einkauf:
- Mehrere Listen (z. B. "Zuhause", "Urlaub"), jede mit eigenem Vorrat und
  eigener Einkaufsliste; Auswahl oben auf der Seite, gemerkt pro Browser
- Beim Anlegen festlegen, wer die Liste sieht: alle Konten oder nur
  ausgewählte (Ersteller sieht sie immer); ändern/löschen nur Ersteller
  und Administratoren
- Gekauft, aufgebraucht, Nachkaufen und Rezeptabgleich wirken innerhalb
  der gewählten Liste
- Migration: bisheriger Vorrat und Einkauf werden zur Liste "Zuhause"
  (für alle sichtbar)

Reisen:
- Schalter "Städte anderer zeigen": Städte der übrigen Konten in je einer
  eigenen Farbe, einzelne Personen ein-/ausblendbar, nur lesend
- Info-Karte zeigt, wer noch in einer Stadt bzw. einem Land war

Design "Fabrik" (Industrie-Inventar à la Satisfactory):
- Stahlplatten mit Schrauben, Titelleisten mit Fenstersymbol, gelb
  leuchtende Hauptbuttons, orange Mengen-Zähler, Warnstreifen,
  Riffelblech und drehende Zahnräder im Hintergrund
- Schriften Roboto / Roboto Condensed (OFL), lokal eingebunden

Doku: README verlinkt das zugehörige Repository P42-S.M.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:50:46 +02:00

326 lines
14 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 json
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 .db import now_iso, transaction
from .links import ValidationError
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
_search_cache: dict[str, list] = {}
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"],
}
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""",
(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 _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],
"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 _text(value, field: str, max_len: int, required: bool = True) -> str:
v = re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else ""
if required and not v:
raise ValidationError(f"{field} fehlt.")
if len(v) > max_len:
raise ValidationError(f"{field} ist zu lang.")
return v
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
values = (
user["id"], geoname_id, _text(data.get("name"), "Name", 100), _text(data.get("admin1"), "Region", 100, False),
country, _number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180),
population, elevation, timezone, 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, 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:
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.")
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"),
}
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
# ---------- 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