"""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}) # ── Parts ───────────────────────────────────────────────────────────────────── def _find_item(items: list[dict], item_id: str) -> tuple[int, dict]: 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") return idx, items[idx] @router.post("/api/gear/{item_id}/parts") async def part_add( 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, item = _find_item(items, item_id) body = await request.json() name = str(body.get("name", "")).strip() if not name: raise HTTPException(400, "name is required") threshold_km = body.get("threshold_km") if threshold_km is not None: try: threshold_km = float(threshold_km) except (TypeError, ValueError): raise HTTPException(400, "threshold_km must be a number") part: dict = {"id": str(uuid.uuid4()), "name": name, "replacements": []} if threshold_km is not None: part["threshold_km"] = threshold_km item = dict(item) item.setdefault("parts", []) item["parts"] = [*item["parts"], part] items[idx] = item _save(user_dir, items) return JSONResponse({"ok": True, "part": part}, status_code=201) @router.patch("/api/gear/{item_id}/parts/{part_id}") async def part_update( item_id: str, part_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, item = _find_item(items, item_id) parts = list(item.get("parts", [])) pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None) if pidx is None: raise HTTPException(404, "Part not found") body = await request.json() part = dict(parts[pidx]) if "name" in body: name = str(body["name"]).strip() if not name: raise HTTPException(400, "name cannot be empty") part["name"] = name if "threshold_km" in body: try: part["threshold_km"] = float(body["threshold_km"]) except (TypeError, ValueError): raise HTTPException(400, "threshold_km must be a number") parts[pidx] = part item = {**item, "parts": parts} items[idx] = item _save(user_dir, items) return JSONResponse({"ok": True, "part": part}) @router.delete("/api/gear/{item_id}/parts/{part_id}") async def part_delete( item_id: str, part_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) idx, item = _find_item(items, item_id) parts = [p for p in item.get("parts", []) if p["id"] != part_id] items[idx] = {**item, "parts": parts} _save(user_dir, items) return JSONResponse({"ok": True}) @router.post("/api/gear/{item_id}/parts/{part_id}/replacements") async def replacement_add( item_id: str, part_id: str, request: Request, bincio_session: str | None = Cookie(default=None), ) -> JSONResponse: """Log a replacement event for a part. date defaults to today (UTC).""" from datetime import UTC, datetime user = deps._require_user(bincio_session) user_dir = deps._get_data_dir() / user.handle items = _load(user_dir) idx, item = _find_item(items, item_id) parts = list(item.get("parts", [])) pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None) if pidx is None: raise HTTPException(404, "Part not found") body = await request.json() date = str(body.get("date", "")).strip() or datetime.now(UTC).strftime("%Y-%m-%d") note = str(body.get("note", "")).strip() or None entry: dict = {"id": str(uuid.uuid4()), "date": date} if note: entry["note"] = note part = dict(parts[pidx]) part["replacements"] = [*part.get("replacements", []), entry] parts[pidx] = part items[idx] = {**item, "parts": parts} _save(user_dir, items) return JSONResponse({"ok": True, "replacement": entry}, status_code=201) @router.delete("/api/gear/{item_id}/parts/{part_id}/replacements/{replacement_id}") async def replacement_delete( item_id: str, part_id: str, replacement_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) idx, item = _find_item(items, item_id) parts = list(item.get("parts", [])) pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None) if pidx is None: raise HTTPException(404, "Part not found") part = dict(parts[pidx]) part["replacements"] = [r for r in part.get("replacements", []) if r["id"] != replacement_id] parts[pidx] = part items[idx] = {**item, "parts": parts} _save(user_dir, items) return JSONResponse({"ok": True})