many changes... added color to slope

This commit is contained in:
Davide Scaini
2026-05-18 21:24:11 +02:00
parent 89f76829b7
commit 67b721d872
6 changed files with 1716 additions and 123 deletions
+235 -6
View File
@@ -2,6 +2,8 @@
Auth: reads bincio_session cookie, validates against shared instance.db (same strategy as bincio_wiki).
Storage: JSON files at $PLANS_DIR/{handle}/{plan_id}.json
Collections: $PLANS_DIR/{handle}/_collections.json
Shared plans: $PLANS_DIR/_shared/{plan_id}.json (any authed user can read/write/delete)
Run: uvicorn server:app --host 127.0.0.1 --port 8002
Env vars:
@@ -21,7 +23,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from fastapi import Cookie, Depends, FastAPI, HTTPException
from fastapi import Cookie, Depends, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
@@ -30,6 +32,7 @@ from pydantic import BaseModel
_SHARED_DB_PATH = Path(os.environ.get("SHARED_DB_PATH", "/var/bincio/data/instance.db"))
_PLANS_DIR = Path(os.environ.get("PLANS_DIR", "/var/bincio_planner"))
_SESSION_COOKIE = "bincio_session"
_DEV_HANDLE = os.environ.get("DEV_HANDLE", "")
_SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
@@ -81,6 +84,8 @@ def _get_session_user(token: str) -> Optional[User]:
async def require_auth(bincio_session: Optional[str] = Cookie(default=None)) -> User:
if _DEV_HANDLE:
return User(handle=_DEV_HANDLE, display_name=_DEV_HANDLE)
if not bincio_session:
raise HTTPException(401, "Authentication required")
user = _get_session_user(bincio_session)
@@ -116,13 +121,54 @@ def _plan_path(handle: str, plan_id: str) -> Path:
return _PLANS_DIR / handle / f"{plan_id}.json"
def _collections_path(handle: str) -> Path:
return _PLANS_DIR / handle / "_collections.json"
def _read_collections(handle: str) -> list[dict]:
p = _collections_path(handle)
if not p.exists():
return []
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return []
def _write_collections(handle: str, cols: list[dict]) -> None:
_user_dir(handle)
_collections_path(handle).write_text(json.dumps(cols), encoding="utf-8")
def _shared_dir() -> Path:
d = _PLANS_DIR / "_shared"
d.mkdir(parents=True, exist_ok=True)
return d
def _shared_plan_path(plan_id: str) -> Path:
if not _SAFE_ID.match(plan_id):
raise HTTPException(400, "Invalid plan id")
return _PLANS_DIR / "_shared" / f"{plan_id}.json"
# ── Endpoints ──────────────────────────────────────────────────────────────────
class PlanBody(BaseModel):
name: str
waypoints: list[dict]
profile: str
geojson: dict | None = None
name: str
waypoints: list[dict]
profile: str
geojson: dict | None = None
gpxTrack: dict | None = None # full GeoJSON of reference track, if any GPX segments used
gpxColorIdx: int | None = None
gpxOpacity: float | None = None
collection_id: str | None = None
dist_km: float | None = None
elevation_gain: int | None = None
class CollectionBody(BaseModel):
name: str
@app.get("/api/plans")
@@ -131,10 +177,13 @@ async def list_plans(user: User = Depends(require_auth)) -> JSONResponse:
if not d.exists():
return JSONResponse({"plans": []})
plans = []
_STRIP = {"geojson", "gpxTrack"}
for p in sorted(d.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True):
if p.name.startswith("_"):
continue
try:
data = json.loads(p.read_text(encoding="utf-8"))
plans.append({k: v for k, v in data.items() if k != "geojson"})
plans.append({k: v for k, v in data.items() if k not in _STRIP})
except Exception:
pass
return JSONResponse({"plans": plans})
@@ -152,8 +201,18 @@ async def create_plan(body: PlanBody, user: User = Depends(require_auth)) -> JSO
"created_at": now,
"updated_at": now,
}
if body.collection_id:
data["collection_id"] = body.collection_id
if body.dist_km is not None:
data["dist_km"] = body.dist_km
if body.elevation_gain is not None:
data["elevation_gain"] = body.elevation_gain
if body.geojson is not None:
data["geojson"] = body.geojson
if body.gpxTrack is not None:
data["gpxTrack"] = body.gpxTrack
data["gpxColorIdx"] = body.gpxColorIdx
data["gpxOpacity"] = body.gpxOpacity
_user_dir(user.handle).joinpath(f"{plan_id}.json").write_text(
json.dumps(data), encoding="utf-8"
)
@@ -182,8 +241,25 @@ async def update_plan(
"profile": body.profile,
"updated_at": int(time.time()),
})
# collection_id: non-null assigns, null unassigns
if body.collection_id:
data["collection_id"] = body.collection_id
else:
data.pop("collection_id", None)
if body.dist_km is not None:
data["dist_km"] = body.dist_km
if body.elevation_gain is not None:
data["elevation_gain"] = body.elevation_gain
if body.geojson is not None:
data["geojson"] = body.geojson
if body.gpxTrack is not None:
data["gpxTrack"] = body.gpxTrack
data["gpxColorIdx"] = body.gpxColorIdx
data["gpxOpacity"] = body.gpxOpacity
elif "gpxTrack" in data:
del data["gpxTrack"]
data.pop("gpxColorIdx", None)
data.pop("gpxOpacity", None)
p.write_text(json.dumps(data), encoding="utf-8")
return JSONResponse({"id": plan_id, "updated": True})
@@ -195,3 +271,156 @@ async def delete_plan(plan_id: str, user: User = Depends(require_auth)) -> JSONR
raise HTTPException(404, "Plan not found")
p.unlink()
return JSONResponse({"id": plan_id, "deleted": True})
# ── Collection endpoints ───────────────────────────────────────────────────────
@app.get("/api/collections")
async def list_collections(user: User = Depends(require_auth)) -> JSONResponse:
return JSONResponse({"collections": _read_collections(user.handle)})
@app.post("/api/collections")
async def create_collection(body: CollectionBody, user: User = Depends(require_auth)) -> JSONResponse:
col_id = secrets.token_urlsafe(12)
now = int(time.time())
cols = _read_collections(user.handle)
cols.append({"id": col_id, "name": body.name.strip() or "Unnamed collection", "created_at": now})
_write_collections(user.handle, cols)
return JSONResponse({"id": col_id, "created_at": now})
@app.put("/api/collections/{col_id}")
async def update_collection(
col_id: str, body: CollectionBody, user: User = Depends(require_auth)
) -> JSONResponse:
cols = _read_collections(user.handle)
for c in cols:
if c["id"] == col_id:
c["name"] = body.name.strip() or c["name"]
_write_collections(user.handle, cols)
return JSONResponse({"id": col_id, "updated": True})
raise HTTPException(404, "Collection not found")
@app.delete("/api/collections/{col_id}")
async def delete_collection(
col_id: str,
mode: str = Query(default="unassign"),
user: User = Depends(require_auth),
) -> JSONResponse:
cols = _read_collections(user.handle)
if not any(c["id"] == col_id for c in cols):
raise HTTPException(404, "Collection not found")
_write_collections(user.handle, [c for c in cols if c["id"] != col_id])
d = _PLANS_DIR / user.handle
if d.exists():
for p in d.glob("*.json"):
if p.name.startswith("_"):
continue
try:
data = json.loads(p.read_text(encoding="utf-8"))
if data.get("collection_id") == col_id:
if mode == "delete":
p.unlink()
else:
data.pop("collection_id", None)
p.write_text(json.dumps(data), encoding="utf-8")
except Exception:
pass
return JSONResponse({"id": col_id, "deleted": True})
# ── Shared plan endpoints ──────────────────────────────────────────────────────
_SHARED_STRIP = {"geojson", "gpxTrack"}
@app.get("/api/shared")
async def list_shared(user: User = Depends(require_auth)) -> JSONResponse:
d = _PLANS_DIR / "_shared"
if not d.exists():
return JSONResponse({"plans": []})
plans = []
for p in sorted(d.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True):
try:
data = json.loads(p.read_text(encoding="utf-8"))
plans.append({k: v for k, v in data.items() if k not in _SHARED_STRIP})
except Exception:
pass
return JSONResponse({"plans": plans})
@app.post("/api/shared")
async def create_shared(body: PlanBody, user: User = Depends(require_auth)) -> JSONResponse:
plan_id = secrets.token_urlsafe(12)
now = int(time.time())
data: dict = {
"id": plan_id,
"author": user.handle,
"name": body.name.strip() or "Unnamed route",
"waypoints": body.waypoints,
"profile": body.profile,
"created_at": now,
"updated_at": now,
}
if body.dist_km is not None:
data["dist_km"] = body.dist_km
if body.elevation_gain is not None:
data["elevation_gain"] = body.elevation_gain
if body.geojson is not None:
data["geojson"] = body.geojson
if body.gpxTrack is not None:
data["gpxTrack"] = body.gpxTrack
data["gpxColorIdx"] = body.gpxColorIdx
data["gpxOpacity"] = body.gpxOpacity
_shared_dir().joinpath(f"{plan_id}.json").write_text(json.dumps(data), encoding="utf-8")
return JSONResponse({"id": plan_id, "created_at": now})
@app.get("/api/shared/{plan_id}")
async def get_shared(plan_id: str, user: User = Depends(require_auth)) -> JSONResponse:
p = _shared_plan_path(plan_id)
if not p.exists():
raise HTTPException(404, "Shared plan not found")
return JSONResponse(json.loads(p.read_text(encoding="utf-8")))
@app.put("/api/shared/{plan_id}")
async def update_shared(
plan_id: str, body: PlanBody, user: User = Depends(require_auth)
) -> JSONResponse:
p = _shared_plan_path(plan_id)
if not p.exists():
raise HTTPException(404, "Shared plan not found")
data = json.loads(p.read_text(encoding="utf-8"))
data.update({
"name": body.name.strip() or data.get("name", "Unnamed route"),
"waypoints": body.waypoints,
"profile": body.profile,
"updated_at": int(time.time()),
})
if body.dist_km is not None:
data["dist_km"] = body.dist_km
if body.elevation_gain is not None:
data["elevation_gain"] = body.elevation_gain
if body.geojson is not None:
data["geojson"] = body.geojson
if body.gpxTrack is not None:
data["gpxTrack"] = body.gpxTrack
data["gpxColorIdx"] = body.gpxColorIdx
data["gpxOpacity"] = body.gpxOpacity
elif "gpxTrack" in data:
del data["gpxTrack"]
data.pop("gpxColorIdx", None)
data.pop("gpxOpacity", None)
p.write_text(json.dumps(data), encoding="utf-8")
return JSONResponse({"id": plan_id, "updated": True})
@app.delete("/api/shared/{plan_id}")
async def delete_shared(plan_id: str, user: User = Depends(require_auth)) -> JSONResponse:
p = _shared_plan_path(plan_id)
if not p.exists():
raise HTTPException(404, "Shared plan not found")
p.unlink()
return JSONResponse({"id": plan_id, "deleted": True})