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>
326 lines
14 KiB
Python
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
|