|
@@ -2,7 +2,7 @@
|
|
|
Dashboard HTTP routes for astro-mcp.
|
|
Dashboard HTTP routes for astro-mcp.
|
|
|
|
|
|
|
|
Serves Jinja2 templates for person management.
|
|
Serves Jinja2 templates for person management.
|
|
|
-All routes are prefixed with /dashboard.
|
|
|
|
|
|
|
+Route ordering matters: static paths MUST be registered before {person_id} catch-all.
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Form, Request
|
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
|
|
|
|
|
from . import storage
|
|
from . import storage
|
|
|
-from .config import GEONAMES_USERNAME
|
|
|
|
|
|
|
+from .config import GEONAMES_USERNAME, DASHBOARD_24H_TIME
|
|
|
from .server import templates
|
|
from .server import templates
|
|
|
|
|
|
|
|
logger = logging.getLogger("astro-mcp.dashboard")
|
|
logger = logging.getLogger("astro-mcp.dashboard")
|
|
@@ -39,9 +39,7 @@ async def _get_one_person(person_id: str) -> dict[str, Any] | None:
|
|
|
|
|
|
|
|
@router.get("/dashboard", response_class=HTMLResponse)
|
|
@router.get("/dashboard", response_class=HTMLResponse)
|
|
|
async def dashboard_home(request: Request):
|
|
async def dashboard_home(request: Request):
|
|
|
- return templates.TemplateResponse(
|
|
|
|
|
- request, "dashboard.html", {}
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ return templates.TemplateResponse(request, "dashboard.html", {})
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── geonames autocomplete ─────────────────────────────────────────────
|
|
# ── geonames autocomplete ─────────────────────────────────────────────
|
|
@@ -68,7 +66,6 @@ async def geonames_search(q: str, max_rows: int = 8):
|
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
|
resp = await client.get(url, params=params)
|
|
resp = await client.get(url, params=params)
|
|
|
data = resp.json()
|
|
data = resp.json()
|
|
|
- # Check for GeoNames error responses
|
|
|
|
|
if "status" in data and "message" in data["status"]:
|
|
if "status" in data and "message" in data["status"]:
|
|
|
logger.warning(f"geonames error: {data['status']['message']}")
|
|
logger.warning(f"geonames error: {data['status']['message']}")
|
|
|
return []
|
|
return []
|
|
@@ -92,19 +89,25 @@ async def geonames_search(q: str, max_rows: int = 8):
|
|
|
logger.warning(f"geonames search failed: {e}")
|
|
logger.warning(f"geonames search failed: {e}")
|
|
|
return []
|
|
return []
|
|
|
|
|
|
|
|
|
|
+
|
|
|
|
|
+# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
+# STATIC PATHS (must all come BEFORE /dashboard/persons/{person_id})
|
|
|
|
|
+# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
+
|
|
|
|
|
+# ── person list ──────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
@router.get("/dashboard/persons", response_class=HTMLResponse)
|
|
@router.get("/dashboard/persons", response_class=HTMLResponse)
|
|
|
async def person_list(request: Request):
|
|
async def person_list(request: Request):
|
|
|
persons = await _get_all_persons()
|
|
persons = await _get_all_persons()
|
|
|
return templates.TemplateResponse(
|
|
return templates.TemplateResponse(
|
|
|
request, "persons.html",
|
|
request, "persons.html",
|
|
|
- {"persons": persons},
|
|
|
|
|
|
|
+ {"persons": persons, "is_24h": DASHBOARD_24H_TIME},
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── add person (submit) ──────────────────────────────────────────────
|
|
# ── add person (submit) ──────────────────────────────────────────────
|
|
|
-# Must be BEFORE /dashboard/persons/{person_id}
|
|
|
|
|
|
|
|
|
|
-@router.post("/dashboard/persons", response_class=HTMLResponse)
|
|
|
|
|
|
|
+@router.post("/dashboard/persons")
|
|
|
async def person_add_submit(
|
|
async def person_add_submit(
|
|
|
request: Request,
|
|
request: Request,
|
|
|
name: str = Form(...),
|
|
name: str = Form(...),
|
|
@@ -123,54 +126,166 @@ async def person_add_submit(
|
|
|
birth_time_known: bool = Form(default=True),
|
|
birth_time_known: bool = Form(default=True),
|
|
|
):
|
|
):
|
|
|
await storage.add_person(
|
|
await storage.add_person(
|
|
|
- name=name,
|
|
|
|
|
- birth_datetime=birth_datetime,
|
|
|
|
|
- latitude=latitude,
|
|
|
|
|
- longitude=longitude,
|
|
|
|
|
- elevation=elevation,
|
|
|
|
|
- nickname=nickname or None,
|
|
|
|
|
- birthplace=birthplace or None,
|
|
|
|
|
- alive=alive,
|
|
|
|
|
- private=private,
|
|
|
|
|
- gender=gender or None,
|
|
|
|
|
- description=description or None,
|
|
|
|
|
- notes=notes or None,
|
|
|
|
|
- tz=tz or None,
|
|
|
|
|
- birth_time_known=birth_time_known,
|
|
|
|
|
|
|
+ name=name, birth_datetime=birth_datetime,
|
|
|
|
|
+ latitude=latitude, longitude=longitude, elevation=elevation,
|
|
|
|
|
+ nickname=nickname or None, birthplace=birthplace or None,
|
|
|
|
|
+ alive=alive, private=private, gender=gender or None,
|
|
|
|
|
+ description=description or None, notes=notes or None,
|
|
|
|
|
+ tz=tz or None, birth_time_known=birth_time_known,
|
|
|
)
|
|
)
|
|
|
return RedirectResponse("/dashboard/persons", status_code=303)
|
|
return RedirectResponse("/dashboard/persons", status_code=303)
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ── add person (form) ────────────────────────────────────────────────
|
|
|
|
|
-# Must be BEFORE /dashboard/persons/{person_id} so "new" is not captured
|
|
|
|
|
|
|
+# ── add person form ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
@router.get("/dashboard/persons/new", response_class=HTMLResponse)
|
|
@router.get("/dashboard/persons/new", response_class=HTMLResponse)
|
|
|
async def person_add_form(request: Request):
|
|
async def person_add_form(request: Request):
|
|
|
return templates.TemplateResponse(
|
|
return templates.TemplateResponse(
|
|
|
request, "person-form.html",
|
|
request, "person-form.html",
|
|
|
- {"person": None, "action": "add"},
|
|
|
|
|
|
|
+ {"person": None, "action": "add", "is_24h": DASHBOARD_24H_TIME},
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── export all persons ───────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/dashboard/persons/export")
|
|
|
|
|
+async def persons_export():
|
|
|
|
|
+ import json as json_mod
|
|
|
|
|
+ from fastapi.responses import Response
|
|
|
|
|
+ persons = await _get_all_persons()
|
|
|
|
|
+ export = []
|
|
|
|
|
+ for p in persons:
|
|
|
|
|
+ export.append({
|
|
|
|
|
+ "name": p.get("name"), "nickname": p.get("nickname"),
|
|
|
|
|
+ "birth_datetime": p.get("birth_datetime"),
|
|
|
|
|
+ "birthplace": p.get("birthplace"),
|
|
|
|
|
+ "latitude": p.get("latitude"), "longitude": p.get("longitude"),
|
|
|
|
|
+ "elevation": p.get("elevation"), "alive": p.get("alive"),
|
|
|
|
|
+ "private": p.get("private"), "gender": p.get("gender"),
|
|
|
|
|
+ "description": p.get("description"), "notes": p.get("notes"),
|
|
|
|
|
+ "timezone": p.get("timezone"),
|
|
|
|
|
+ "birth_time_known": p.get("birth_time_known"),
|
|
|
|
|
+ })
|
|
|
|
|
+ content = json_mod.dumps(export, indent=2, ensure_ascii=False)
|
|
|
|
|
+ return Response(
|
|
|
|
|
+ content=content, media_type="application/json",
|
|
|
|
|
+ headers={"Content-Disposition": "attachment; filename=persons.json"},
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── import form ──────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/dashboard/persons/import", response_class=HTMLResponse)
|
|
|
|
|
+async def person_import_form(request: Request):
|
|
|
|
|
+ return templates.TemplateResponse(
|
|
|
|
|
+ request, "person-import.html",
|
|
|
|
|
+ {"imported": None, "errors": None},
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# ── import submit ────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/dashboard/persons/import", response_class=HTMLResponse)
|
|
|
|
|
+async def person_import_submit(request: Request):
|
|
|
|
|
+ import json as json_mod
|
|
|
|
|
+ try:
|
|
|
|
|
+ form = await request.form()
|
|
|
|
|
+ file = form.get("import_file")
|
|
|
|
|
+ if not file:
|
|
|
|
|
+ return templates.TemplateResponse(
|
|
|
|
|
+ request, "person-import.html",
|
|
|
|
|
+ {"imported": None, "errors": ["No file uploaded."]},
|
|
|
|
|
+ )
|
|
|
|
|
+ raw = await file.read()
|
|
|
|
|
+ data = json_mod.loads(raw)
|
|
|
|
|
+ if isinstance(data, dict):
|
|
|
|
|
+ data = [data]
|
|
|
|
|
+ imported = 0
|
|
|
|
|
+ errors = []
|
|
|
|
|
+ for i, entry in enumerate(data):
|
|
|
|
|
+ try:
|
|
|
|
|
+ name = entry.get("name")
|
|
|
|
|
+ bdt = entry.get("birth_datetime")
|
|
|
|
|
+ lat = entry.get("latitude")
|
|
|
|
|
+ lon = entry.get("longitude")
|
|
|
|
|
+ if not name or not bdt or lat is None or lon is None:
|
|
|
|
|
+ errors.append(f"Row {i+1}: missing required fields")
|
|
|
|
|
+ continue
|
|
|
|
|
+ await storage.add_person(
|
|
|
|
|
+ name=name, birth_datetime=bdt,
|
|
|
|
|
+ latitude=float(lat), longitude=float(lon),
|
|
|
|
|
+ elevation=float(entry.get("elevation", 0) or 0),
|
|
|
|
|
+ nickname=entry.get("nickname") or None,
|
|
|
|
|
+ birthplace=entry.get("birthplace") or None,
|
|
|
|
|
+ alive=bool(entry.get("alive", True)),
|
|
|
|
|
+ private=bool(entry.get("private", False)),
|
|
|
|
|
+ gender=entry.get("gender") or None,
|
|
|
|
|
+ description=entry.get("description") or None,
|
|
|
|
|
+ notes=entry.get("notes") or None,
|
|
|
|
|
+ tz=entry.get("timezone") or None,
|
|
|
|
|
+ birth_time_known=bool(entry.get("birth_time_known", True)),
|
|
|
|
|
+ )
|
|
|
|
|
+ imported += 1
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ errors.append(f"Row {i+1}: {e}")
|
|
|
|
|
+ return templates.TemplateResponse(
|
|
|
|
|
+ request, "person-import.html",
|
|
|
|
|
+ {"imported": imported, "errors": errors},
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ return templates.TemplateResponse(
|
|
|
|
|
+ request, "person-import.html",
|
|
|
|
|
+ {"imported": None, "errors": [f"Import failed: {e}"]},
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
+# PARAMETERIZED PATHS ({person_id}) — all static paths registered above
|
|
|
|
|
+# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
+
|
|
|
# ── person detail ────────────────────────────────────────────────────
|
|
# ── person detail ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
@router.get("/dashboard/persons/{person_id:str}", response_class=HTMLResponse)
|
|
@router.get("/dashboard/persons/{person_id:str}", response_class=HTMLResponse)
|
|
|
async def person_detail(request: Request, person_id: str):
|
|
async def person_detail(request: Request, person_id: str):
|
|
|
- if person_id == "new":
|
|
|
|
|
- return RedirectResponse("/dashboard/persons/new", status_code=307)
|
|
|
|
|
person = await _get_one_person(person_id)
|
|
person = await _get_one_person(person_id)
|
|
|
if not person:
|
|
if not person:
|
|
|
return RedirectResponse("/dashboard/persons", status_code=303)
|
|
return RedirectResponse("/dashboard/persons", status_code=303)
|
|
|
return templates.TemplateResponse(
|
|
return templates.TemplateResponse(
|
|
|
- request, "person-detail.html",
|
|
|
|
|
- {"person": person},
|
|
|
|
|
|
|
+ request, "person-detail.html", {"person": person},
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── person export (single) ───────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/dashboard/persons/{person_id:str}/export")
|
|
|
|
|
+async def person_export(person_id: str):
|
|
|
|
|
+ import json as json_mod
|
|
|
|
|
+ from fastapi.responses import Response
|
|
|
|
|
+ person = await _get_one_person(person_id)
|
|
|
|
|
+ if not person:
|
|
|
|
|
+ return RedirectResponse("/dashboard/persons", status_code=303)
|
|
|
|
|
+ export = {
|
|
|
|
|
+ "name": person.get("name"), "nickname": person.get("nickname"),
|
|
|
|
|
+ "birth_datetime": person.get("birth_datetime"),
|
|
|
|
|
+ "birthplace": person.get("birthplace"),
|
|
|
|
|
+ "latitude": person.get("latitude"), "longitude": person.get("longitude"),
|
|
|
|
|
+ "elevation": person.get("elevation"), "alive": person.get("alive"),
|
|
|
|
|
+ "private": person.get("private"), "gender": person.get("gender"),
|
|
|
|
|
+ "description": person.get("description"), "notes": person.get("notes"),
|
|
|
|
|
+ "timezone": person.get("timezone"),
|
|
|
|
|
+ "birth_time_known": person.get("birth_time_known"),
|
|
|
|
|
+ }
|
|
|
|
|
+ content = json_mod.dumps(export, indent=2, ensure_ascii=False)
|
|
|
|
|
+ safe_name = person.get("name", person_id).replace(" ", "_")
|
|
|
|
|
+ return Response(
|
|
|
|
|
+ content=content, media_type="application/json",
|
|
|
|
|
+ headers={"Content-Disposition": f"attachment; filename=person_{safe_name}.json"},
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── edit person (submit) ─────────────────────────────────────────────
|
|
# ── edit person (submit) ─────────────────────────────────────────────
|
|
|
-# Must be BEFORE /dashboard/persons/{person_id}
|
|
|
|
|
|
|
|
|
|
-@router.post("/dashboard/persons/{person_id}", response_class=HTMLResponse)
|
|
|
|
|
|
|
+@router.post("/dashboard/persons/{person_id:str}")
|
|
|
async def person_edit_submit(
|
|
async def person_edit_submit(
|
|
|
request: Request,
|
|
request: Request,
|
|
|
person_id: str,
|
|
person_id: str,
|
|
@@ -190,41 +305,32 @@ async def person_edit_submit(
|
|
|
birth_time_known: bool = Form(default=True),
|
|
birth_time_known: bool = Form(default=True),
|
|
|
):
|
|
):
|
|
|
await storage.update_person(
|
|
await storage.update_person(
|
|
|
- person_id=person_id,
|
|
|
|
|
- name=name,
|
|
|
|
|
- birth_datetime=birth_datetime,
|
|
|
|
|
- latitude=latitude,
|
|
|
|
|
- longitude=longitude,
|
|
|
|
|
- elevation=elevation,
|
|
|
|
|
- nickname=nickname or None,
|
|
|
|
|
- birthplace=birthplace or None,
|
|
|
|
|
- alive=alive,
|
|
|
|
|
- private=private,
|
|
|
|
|
- gender=gender or None,
|
|
|
|
|
- description=description or None,
|
|
|
|
|
- notes=notes or None,
|
|
|
|
|
- tz=tz or None,
|
|
|
|
|
- birth_time_known=birth_time_known,
|
|
|
|
|
|
|
+ person_id=person_id, name=name, birth_datetime=birth_datetime,
|
|
|
|
|
+ latitude=latitude, longitude=longitude, elevation=elevation,
|
|
|
|
|
+ nickname=nickname or None, birthplace=birthplace or None,
|
|
|
|
|
+ alive=alive, private=private, gender=gender or None,
|
|
|
|
|
+ description=description or None, notes=notes or None,
|
|
|
|
|
+ tz=tz or None, birth_time_known=birth_time_known,
|
|
|
)
|
|
)
|
|
|
return RedirectResponse(f"/dashboard/persons/{person_id}", status_code=303)
|
|
return RedirectResponse(f"/dashboard/persons/{person_id}", status_code=303)
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ── edit person (form) ───────────────────────────────────────────────
|
|
|
|
|
|
|
+# ── edit person form ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
-@router.get("/dashboard/persons/{person_id}/edit", response_class=HTMLResponse)
|
|
|
|
|
|
|
+@router.get("/dashboard/persons/{person_id:str}/edit", response_class=HTMLResponse)
|
|
|
async def person_edit_form(request: Request, person_id: str):
|
|
async def person_edit_form(request: Request, person_id: str):
|
|
|
person = await _get_one_person(person_id)
|
|
person = await _get_one_person(person_id)
|
|
|
if not person:
|
|
if not person:
|
|
|
return RedirectResponse("/dashboard/persons", status_code=303)
|
|
return RedirectResponse("/dashboard/persons", status_code=303)
|
|
|
return templates.TemplateResponse(
|
|
return templates.TemplateResponse(
|
|
|
request, "person-form.html",
|
|
request, "person-form.html",
|
|
|
- {"person": person, "action": "edit"},
|
|
|
|
|
|
|
+ {"person": person, "action": "edit", "is_24h": DASHBOARD_24H_TIME},
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── delete person ────────────────────────────────────────────────────
|
|
# ── delete person ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
-@router.post("/dashboard/persons/{person_id}/delete", response_class=HTMLResponse)
|
|
|
|
|
|
|
+@router.post("/dashboard/persons/{person_id:str}/delete")
|
|
|
async def person_delete(person_id: str):
|
|
async def person_delete(person_id: str):
|
|
|
await storage.delete_person(person_id)
|
|
await storage.delete_person(person_id)
|
|
|
return RedirectResponse("/dashboard/persons", status_code=303)
|
|
return RedirectResponse("/dashboard/persons", status_code=303)
|