P42 Dashboard: erster Stand
FastAPI-Backend und React-Frontend mit Konten, Sendungsverfolgung, Projekt-Artikeln, Vorrat & Einkauf mit Rezeptabgleich, Reise-Weltkarte, Kanban-Projekten mit Priorität, Server-Monitoring und den Designs Matrix und Blackwall. Betrieb per Docker hinter nginx (siehe DEPLOY.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dd58b3d1f8
75 files changed
+11418
No files matched your search
@@ -0,0 +1,305 @@
|
||||
"""Reise-Karte pro Konto: besuchte Städte und Länder sowie kurze Infos dazu.
|
||||
|
||||
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 _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]}
|
||||
|
||||
|
||||
# ---------- 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
|
||||
Reference in new issue
Block a user