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:
guyg1474andClaude Opus 5.5 committed 2026-10-14 14:08:11 +02:00
commit dd58b3d1f8
75 files changed
+11418

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
"""Universelle Sendungsverfolgung: erst DHL direkt, dann 17TRACK, sonst nur Erkennung + Links."""
import logging
import time
from ..config import TRACKING
from .carriers import detect_carriers, universal_link
from .dhl import track_dhl
from .seventeen import track_17
log = logging.getLogger(__name__)
CACHE_SECONDS = 120
_cache: dict[str, tuple[float, dict]] = {}
async def track(number: str) -> dict:
cached = _cache.get(number)
if cached and time.monotonic() - cached[0] < CACHE_SECONDS:
return cached[1]
candidates = detect_carriers(number)
base = {"number": number, "candidates": candidates, "universalLink": universal_link(number)}
dhl_key = TRACKING.get("dhlApiKey")
key_17 = TRACKING.get("seventeenTrackApiKey")
errors = []
if dhl_key and any(c["id"] == "dhl" for c in candidates):
try:
result = await track_dhl(number, dhl_key)
if result:
return _store(number, {**base, **result})
except Exception as e: # noqa: BLE001 – Fehler anzeigen, nächsten Anbieter probieren
log.warning("DHL-Abfrage fehlgeschlagen: %s", e)
errors.append(f"DHL: {e}")
if key_17:
try:
result = await track_17(number, key_17)
data = {**base, **result}
return _store(number, data) if result["status"] != "pending" else data
except Exception as e: # noqa: BLE001
log.warning("17TRACK-Abfrage fehlgeschlagen: %s", e)
errors.append(f"17TRACK: {e}")
return {**base, "source": None, "status": "unknown", "configured": bool(dhl_key or key_17), "errors": errors}
def _store(number: str, data: dict) -> dict:
_cache[number] = (time.monotonic(), data)
return data
+37
View File
@@ -0,0 +1,37 @@
"""Erkennt anhand des Nummernformats, welche Paketdienste in Frage kommen.
Viele Formate überschneiden sich, deshalb kann es mehrere Treffer geben.
"""
import re
from urllib.parse import quote
def universal_link(number: str) -> str:
return f"https://t.17track.net/de#nums={quote(number)}"
_DHL = "https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode={}"
CARRIERS = [
("dhl", "DHL", [r"\d{12}", r"\d{20}", r"(JJD|JVGL)\d{8,}", r"00340\d{15}", r"\d{10}"], _DHL),
("deutschepost", "Deutsche Post", [r"[A-Z]{2}\d{9}DE"], _DHL),
("ups", "UPS", [r"1Z[0-9A-Z]{16}"], "https://www.ups.com/track?loc=de_DE&tracknum={}"),
("hermes", "Hermes", [r"\d{14}", r"\d{16}", r"H\d{19}"],
"https://www.myhermes.de/empfangen/sendungsverfolgung/sendungsinformation#{}"),
("dpd", "DPD", [r"\d{14}", r"\d{27,28}"], "https://tracking.dpd.de/status/de_DE/parcel/{}"),
("gls", "GLS", [r"\d{11,12}", r"[A-Z0-9]{8}"], "https://gls-group.com/DE/de/paketverfolgung?match={}"),
("fedex", "FedEx", [r"\d{12}", r"\d{15}"], "https://www.fedex.com/fedextrack/?trknbr={}"),
("amazon", "Amazon Logistics", [r"TBA\d{9,}"], "https://www.amazon.de/gp/your-account/order-history"),
("upu", "Internationale Post (UPU)", [r"[A-Z]{2}\d{9}(?!DE)[A-Z]{2}"], None),
]
_COMPILED = [(cid, name, [re.compile(p) for p in patterns], link) for cid, name, patterns, link in CARRIERS]
def detect_carriers(number: str) -> list[dict]:
result = []
for cid, name, patterns, link in _COMPILED:
if any(p.fullmatch(number) for p in patterns):
url = link.format(quote(number)) if link else universal_link(number)
result.append({"id": cid, "name": name, "link": url})
return result
+64
View File
@@ -0,0 +1,64 @@
"""DHL Shipment Tracking – Unified API.
Kostenloser Key: https://developer.dhl.com (API "Shipment Tracking - Unified")
"""
import httpx
API = "https://api-eu.dhl.com/track/shipments"
STATUS = {
"pre-transit": "inforeceived",
"transit": "intransit",
"delivered": "delivered",
"failure": "failed",
"unknown": "unknown",
}
def _place(loc: dict | None) -> str | None:
addr = (loc or {}).get("address")
if not addr:
return None
return ", ".join(filter(None, [addr.get("addressLocality"), addr.get("countryCode")])) or None
async def track_dhl(number: str, key: str) -> dict | None:
async with httpx.AsyncClient(timeout=15) as client:
res = await client.get(
API,
params={"trackingNumber": number, "language": "de"},
headers={"DHL-API-Key": key, "Accept": "application/json"},
)
if res.status_code == 404:
return None # keine DHL-Sendung -> nächsten Anbieter probieren
res.raise_for_status()
shipments = res.json().get("shipments") or []
if not shipments:
return None
s = shipments[0]
status = s.get("status") or {}
events = [
{"time": e.get("timestamp"), "description": e.get("description") or e.get("status"), "location": _place(e.get("location"))}
for e in s.get("events") or []
]
eta = None
if s.get("estimatedTimeOfDelivery"):
eta = {"from": s["estimatedTimeOfDelivery"], "to": None}
elif s.get("estimatedDeliveryTimeFrame"):
frame = s["estimatedDeliveryTimeFrame"]
eta = {"from": frame.get("estimatedFrom"), "to": frame.get("estimatedThrough")}
return {
"source": "DHL API",
"carrier": "DHL Express" if s.get("service") == "express" else "DHL",
"status": STATUS.get(status.get("statusCode"), "unknown"),
"statusDetail": status.get("description") or status.get("status"),
"eta": eta,
"deliveredAt": status.get("timestamp") if status.get("statusCode") == "delivered" else None,
"origin": _place(s.get("origin")),
"destination": _place(s.get("destination")),
"events": events,
}
+80
View File
@@ -0,0 +1,80 @@
"""17TRACK Tracking API v2.2 – https://api.17track.net
Nummern müssen erst registriert werden, danach lassen sich die Daten abrufen.
Bei neu registrierten Nummern dauert es oft ein paar Sekunden, bis Daten da sind.
"""
import httpx
API = "https://api.17track.net/track/v2.2"
ALREADY_REGISTERED = -18019901
STATUS = {
"NotFound": "notfound",
"InfoReceived": "inforeceived",
"InTransit": "intransit",
"Expired": "expired",
"AvailableForPickup": "pickup",
"OutForDelivery": "outfordelivery",
"DeliveryFailure": "failed",
"Delivered": "delivered",
"Exception": "exception",
}
async def _call(client: httpx.AsyncClient, endpoint: str, key: str, body: list) -> dict:
res = await client.post(f"{API}/{endpoint}", headers={"17token": key}, json=body)
res.raise_for_status()
data = res.json()
if data.get("code") != 0:
raise RuntimeError(f"API-Code {data.get('code')}")
return data["data"]
def _location(event: dict) -> str | None:
if event.get("location"):
return event["location"]
addr = event.get("address") or {}
return ", ".join(filter(None, [addr.get("city"), addr.get("country")])) or None
async def track_17(number: str, key: str) -> dict:
async with httpx.AsyncClient(timeout=15) as client:
data = await _call(client, "gettrackinfo", key, [{"number": number}])
if not data.get("accepted"):
# Noch nicht registriert -> registrieren (17TRACK erkennt den Paketdienst selbst)
reg = await _call(client, "register", key, [{"number": number}])
rejected = (reg.get("rejected") or [None])[0]
if rejected and (rejected.get("error") or {}).get("code") != ALREADY_REGISTERED:
raise RuntimeError((rejected.get("error") or {}).get("message") or "Registrierung abgelehnt")
data = await _call(client, "gettrackinfo", key, [{"number": number}])
item = (data.get("accepted") or [None])[0]
if not item or not item.get("track_info"):
return {"source": "17TRACK", "status": "pending"}
info = item["track_info"]
provider = ((info.get("tracking") or {}).get("providers") or [{}])[0]
events = [
{
"time": e.get("time_iso") or e.get("time_utc"),
"description": e.get("description"),
"location": _location(e),
}
for e in provider.get("events") or []
]
latest = info.get("latest_status") or {}
eta = (info.get("time_metrics") or {}).get("estimated_delivery_date") or {}
shipping = info.get("shipping_info") or {}
return {
"source": "17TRACK",
"carrier": (provider.get("provider") or {}).get("name"),
"status": STATUS.get(latest.get("status"), "unknown"),
"statusDetail": latest.get("sub_status"),
"eta": {"from": eta.get("from"), "to": eta.get("to")} if eta.get("from") or eta.get("to") else None,
"deliveredAt": events[0]["time"] if latest.get("status") == "Delivered" and events else None,
"origin": (shipping.get("shipper_address") or {}).get("country"),
"destination": (shipping.get("recipient_address") or {}).get("country"),
"events": events,
}