feat: gear registry — manage bikes/shoes per athlete, set per activity
- New /api/gear CRUD endpoints (gear.json per user) - Gear tab in AthleteView (owner-only): add, edit, retire items - EditDrawer gear field becomes a dropdown when registry has items - Strava API sync now resolves gear_id → name, adds to registry automatically - Strava ZIP import reads Gear column from activities.csv - POST /api/strava/import-gear for one-time backfill from stored originals
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""Gear registry endpoints (/api/gear)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from bincio.serve import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_GEAR_TYPES = {"bike", "shoes", "skis", "other"}
|
||||
|
||||
|
||||
def _gear_path(user_dir: Path) -> Path:
|
||||
return user_dir / "gear.json"
|
||||
|
||||
|
||||
def _load(user_dir: Path) -> list[dict]:
|
||||
p = _gear_path(user_dir)
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
return data.get("items", [])
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
|
||||
def _save(user_dir: Path, items: list[dict]) -> None:
|
||||
_gear_path(user_dir).write_text(
|
||||
json.dumps({"items": items}, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/gear")
|
||||
async def gear_list(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
items = _load(deps._get_data_dir() / user.handle)
|
||||
return JSONResponse({"items": items})
|
||||
|
||||
|
||||
@router.post("/api/gear")
|
||||
async def gear_add(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
name = str(body.get("name", "")).strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
gear_type = str(body.get("type", "other")).strip()
|
||||
if gear_type not in _GEAR_TYPES:
|
||||
raise HTTPException(400, f"type must be one of: {', '.join(sorted(_GEAR_TYPES))}")
|
||||
strava_id = str(body.get("strava_id", "")).strip() or None
|
||||
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
|
||||
# Deduplicate by strava_id if provided
|
||||
if strava_id and any(i.get("strava_id") == strava_id for i in items):
|
||||
existing = next(i for i in items if i.get("strava_id") == strava_id)
|
||||
return JSONResponse({"ok": True, "item": existing, "created": False})
|
||||
|
||||
item: dict = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"type": gear_type,
|
||||
"retired": False,
|
||||
}
|
||||
if strava_id:
|
||||
item["strava_id"] = strava_id
|
||||
|
||||
items.append(item)
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "item": item, "created": True}, status_code=201)
|
||||
|
||||
|
||||
@router.patch("/api/gear/{item_id}")
|
||||
async def gear_update(
|
||||
item_id: str,
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
|
||||
idx = next((i for i, g in enumerate(items) if g["id"] == item_id), None)
|
||||
if idx is None:
|
||||
raise HTTPException(404, "Gear item not found")
|
||||
|
||||
body = await request.json()
|
||||
item = dict(items[idx])
|
||||
|
||||
if "name" in body:
|
||||
name = str(body["name"]).strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name cannot be empty")
|
||||
item["name"] = name
|
||||
if "type" in body:
|
||||
gear_type = str(body["type"]).strip()
|
||||
if gear_type not in _GEAR_TYPES:
|
||||
raise HTTPException(400, f"type must be one of: {', '.join(sorted(_GEAR_TYPES))}")
|
||||
item["type"] = gear_type
|
||||
if "retired" in body:
|
||||
item["retired"] = bool(body["retired"])
|
||||
|
||||
items[idx] = item
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "item": item})
|
||||
|
||||
|
||||
@router.delete("/api/gear/{item_id}")
|
||||
async def gear_delete(
|
||||
item_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
|
||||
before = len(items)
|
||||
items = [g for g in items if g["id"] != item_id]
|
||||
if len(items) == before:
|
||||
raise HTTPException(404, "Gear item not found")
|
||||
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True})
|
||||
Reference in New Issue
Block a user