fix edit profile

This commit is contained in:
Davide Scaini
2026-04-09 13:21:47 +02:00
parent fb202b4edf
commit 5a29259259
2 changed files with 173 additions and 3 deletions
+170 -1
View File
@@ -15,7 +15,7 @@ import time
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from fastapi import Cookie, FastAPI, HTTPException, Request, Response from fastapi import Cookie, FastAPI, File, HTTPException, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -320,6 +320,175 @@ async def post_activity(
return JSONResponse({"ok": True}) return JSONResponse({"ok": True})
@app.get("/api/activity/{activity_id}/images")
async def list_images(
activity_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = _require_user(bincio_session)
_check_id(activity_id)
dd = _get_data_dir() / user.handle
images_dir = dd / "edits" / "images" / activity_id
images = sorted(p.name for p in images_dir.iterdir() if p.is_file()) if images_dir.exists() else []
return JSONResponse({"images": images})
@app.post("/api/activity/{activity_id}/images")
async def upload_image(
activity_id: str,
file: UploadFile = File(...),
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = _require_user(bincio_session)
_check_id(activity_id)
dd = _get_data_dir() / user.handle
if not (dd / "activities" / f"{activity_id}.json").exists():
raise HTTPException(404, "Activity not found")
if not file.filename:
raise HTTPException(400, "No filename")
ct = file.content_type or ""
if not ct.startswith("image/"):
raise HTTPException(400, f"Only image files accepted (got {ct})")
images_dir = dd / "edits" / "images" / activity_id
images_dir.mkdir(parents=True, exist_ok=True)
safe_name = Path(file.filename).name
(images_dir / safe_name).write_bytes(await file.read())
_trigger_rebuild(user.handle)
return JSONResponse({"ok": True, "filename": safe_name})
@app.delete("/api/activity/{activity_id}/images/{filename}")
async def delete_image(
activity_id: str,
filename: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = _require_user(bincio_session)
_check_id(activity_id)
dd = _get_data_dir() / user.handle
import shutil
safe_name = Path(filename).name
target = dd / "edits" / "images" / activity_id / safe_name
if target.exists() and target.is_file():
target.unlink()
if target.parent.exists() and not any(target.parent.iterdir()):
shutil.rmtree(target.parent)
_trigger_rebuild(user.handle)
return JSONResponse({"ok": True})
@app.get("/api/athlete")
async def get_athlete(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
user = _require_user(bincio_session)
dd = _get_data_dir() / user.handle
athlete_path = dd / "athlete.json"
if not athlete_path.exists():
raise HTTPException(404, "athlete.json not found — run bincio extract first")
data = json.loads(athlete_path.read_text(encoding="utf-8"))
# Layer edits/athlete.yaml on top
edits_path = dd / "edits" / "athlete.yaml"
if edits_path.exists():
try:
import yaml
edits = yaml.safe_load(edits_path.read_text(encoding="utf-8")) or {}
for k in ("max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"):
if k in edits:
data[k] = edits[k]
except Exception:
pass
return JSONResponse({
"max_hr": data.get("max_hr"),
"ftp_w": data.get("ftp_w"),
"hr_zones": data.get("hr_zones"),
"power_zones": data.get("power_zones"),
"seasons": data.get("seasons", []),
"gear": data.get("gear", {}),
})
@app.post("/api/athlete")
async def save_athlete(
request: Request,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = _require_user(bincio_session)
dd = _get_data_dir() / user.handle
if not (dd / "athlete.json").exists():
raise HTTPException(404, "athlete.json not found — run bincio extract first")
payload = await request.json()
edits_dir = dd / "edits"
edits_dir.mkdir(exist_ok=True)
overrides: dict[str, Any] = {}
if payload.get("max_hr") is not None:
overrides["max_hr"] = int(payload["max_hr"])
if payload.get("ftp_w") is not None:
overrides["ftp_w"] = int(payload["ftp_w"])
if payload.get("hr_zones") is not None:
overrides["hr_zones"] = [[int(lo), int(hi)] for lo, hi in payload["hr_zones"]]
if payload.get("power_zones") is not None:
overrides["power_zones"] = [[int(lo), int(hi)] for lo, hi in payload["power_zones"]]
if payload.get("seasons") is not None:
overrides["seasons"] = [
{"name": str(s["name"]), "start": str(s["start"]), "end": str(s["end"])}
for s in payload["seasons"]
]
if payload.get("gear") is not None:
overrides["gear"] = payload["gear"]
import yaml
(edits_dir / "athlete.yaml").write_text(
yaml.dump(overrides, allow_unicode=True, default_flow_style=False),
encoding="utf-8",
)
from bincio.render.merge import merge_all
merge_all(dd)
_trigger_rebuild(user.handle)
return JSONResponse({"ok": True})
_SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"}
@app.post("/api/upload")
async def upload_activity(
file: UploadFile = File(...),
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
user = _require_user(bincio_session)
dd = _get_data_dir() / user.handle
name = Path(file.filename or "upload.fit").name
p = Path(name.lower())
suffix = (p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz") if p.suffix == ".gz" else p.suffix
if suffix not in _SUPPORTED_SUFFIXES:
raise HTTPException(400, f"Unsupported file type '{suffix}'")
contents = await file.read()
if len(contents) > 50 * 1024 * 1024:
raise HTTPException(413, "File too large (max 50 MB)")
staging = dd / "_uploads"
staging.mkdir(exist_ok=True)
staged = staging / name
staged.write_bytes(contents)
try:
from bincio.extract.ingest import ingest_parsed
from bincio.extract.parsers.factory import parse_file
activity = parse_file(staged)
activity_id_check = dd / "activities" / f"{activity.source_file}.json"
from bincio.extract.writer import make_activity_id
activity_id = make_activity_id(activity)
if (dd / "activities" / f"{activity_id}.json").exists():
raise HTTPException(409, f"Activity already exists: {activity_id}")
ingest_parsed(activity, dd, privacy="public")
from bincio.render.merge import merge_all
merge_all(dd)
_trigger_rebuild(user.handle)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(422, f"Failed to process activity: {type(exc).__name__}: {exc}")
finally:
staged.unlink(missing_ok=True)
return JSONResponse({"ok": True, "id": activity_id})
@app.post("/api/strava/sync") @app.post("/api/strava/sync")
async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
user = _require_user(bincio_session) user = _require_user(bincio_session)
+3 -2
View File
@@ -23,6 +23,7 @@
let mounted = false; let mounted = false;
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
const editEnabled = editUrl !== '' || import.meta.env.PUBLIC_EDIT_ENABLED === 'true';
$: if (mounted) { $: if (mounted) {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -94,7 +95,7 @@
>{tab.label}</button> >{tab.label}</button>
{/each} {/each}
</nav> </nav>
{#if editUrl} {#if editEnabled}
<button <button
on:click={() => drawerOpen = true} on:click={() => drawerOpen = true}
class="mb-2 px-3 py-1.5 text-xs border border-zinc-700 hover:border-zinc-500 text-zinc-400 hover:text-white rounded-md transition-colors" class="mb-2 px-3 py-1.5 text-xs border border-zinc-700 hover:border-zinc-500 text-zinc-400 hover:text-white rounded-md transition-colors"
@@ -168,7 +169,7 @@
{/if} {/if}
{#if drawerOpen && editUrl} {#if drawerOpen && editEnabled}
<AthleteDrawer <AthleteDrawer
{editUrl} {editUrl}
on:close={() => drawerOpen = false} on:close={() => drawerOpen = false}