Multi-user via PocketID: account linking, group gating, admin user management

PocketID OIDC already auto-provisioned users keyed by pocketid_sub, and the
data layer was already fully user-scoped. This adds the missing pieces for
running real multi-user:

- auth.py callback: link by email to an existing un-linked account (so the
  admin keeps their data when first signing in by passkey), collision-safe
  username generation, and request the `groups` scope.
- Group gating: optional pocketid_allowed_group (admin-config or
  POCKETID_ALLOWED_GROUP env); users lacking the group are rejected at the
  callback and redirected to /login?auth_error=not_authorized.
- New admin users API (app/api/users.py): list users, promote/demote admin
  (guards against demoting/locking out the last admin or yourself), and delete
  a user with ordered bulk deletes of all their data + on-disk files.
- ProfilePage: allowed-group field; LoginPage: rejected-login message;
  Layout: admin-only Users nav; new UsersPage.

Resync milevault_export to current source (it had drifted many features behind
— missing garmin_sync, npm-ci Dockerfile and @polyline-codec that broke its own
CI) and add POCKETID_ALLOWED_GROUP to .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:19:55 +01:00
parent bc4d68da07
commit 0e4bc7b444
46 changed files with 3282 additions and 588 deletions
+60 -2
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from sqlalchemy import select, func, desc, delete
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
@@ -75,6 +75,30 @@ class LapOut(BaseModel):
from_attributes = True
@router.get("/stats/ytd")
async def ytd_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return year-to-date distance totals grouped by sport type."""
from datetime import date, timezone
year_start = datetime(date.today().year, 1, 1, tzinfo=timezone.utc)
result = await db.execute(
select(Activity.sport_type, func.sum(Activity.distance_m).label("total_m"))
.where(Activity.user_id == current_user.id, Activity.start_time >= year_start)
.group_by(Activity.sport_type)
)
rows = result.all()
totals = {r.sport_type: (r.total_m or 0) / 1000 for r in rows}
return {
"running_km": round(totals.get("running", 0), 2),
"cycling_km": round(totals.get("cycling", 0), 2),
"hiking_km": round(totals.get("hiking", 0), 2),
"walking_km": round(totals.get("walking", 0), 2),
"total_km": round(sum(totals.values()), 2),
}
@router.get("/", response_model=List[ActivitySummary])
async def list_activities(
page: int = Query(1, ge=1),
@@ -126,7 +150,6 @@ async def get_data_points(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Verify ownership
act = await db.execute(
select(Activity).where(
Activity.id == activity_id,
@@ -211,3 +234,38 @@ async def delete_activity(
raise HTTPException(status_code=404, detail="Activity not found")
await db.delete(activity)
await db.commit()
@router.post("/{activity_id}/reprocess")
async def reprocess_activity(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Re-parse the source FIT file and update polyline, data points etc."""
import os
result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
if not activity.source_file:
raise HTTPException(status_code=400, detail="No source file stored for this activity")
if not os.path.exists(activity.source_file):
raise HTTPException(status_code=404, detail="Source file no longer exists on disk")
source_file = activity.source_file
source_type = activity.source_type or "fit"
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity_id))
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id == activity_id))
await db.delete(activity)
await db.commit()
from app.workers.tasks import process_activity_file
task = process_activity_file.delay(source_file, current_user.id, source_type)
return {"task_id": task.id, "status": "queued"}
+55 -7
View File
@@ -24,6 +24,27 @@ async def _get_pocketid_config(db: AsyncSession):
return issuer, client_id, client_secret
async def _get_allowed_group(db: AsyncSession):
"""Group a PocketID user must belong to in order to sign in (None = allow all)."""
result = await db.execute(select(User).where(User.is_admin == True).limit(1))
admin = result.scalar_one_or_none()
group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group
return (group or "").strip() or None
async def _unique_username(db: AsyncSession, base: str) -> str:
"""Return `base`, or `base-2`, `base-3`, … until it is not already taken."""
base = (base or "user").strip() or "user"
candidate = base
n = 1
while True:
existing = await db.execute(select(User).where(User.username == candidate))
if existing.scalar_one_or_none() is None:
return candidate
n += 1
candidate = f"{base}-{n}"
class Token(BaseModel):
access_token: str
token_type: str
@@ -77,9 +98,9 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": "/api/auth/pocketid/callback",
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
"response_type": "code",
"scope": "openid profile email",
"scope": "openid profile email groups",
}
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@@ -92,31 +113,58 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{issuer}/token",
f"{issuer}/api/oidc/token",
data={"grant_type": "authorization_code", "code": code,
"redirect_uri": "/api/auth/pocketid/callback",
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
"client_id": client_id, "client_secret": client_secret},
)
if resp.status_code != 200:
raise HTTPException(status_code=400, detail="Token exchange failed")
tokens = resp.json()
userinfo_resp = await client.get(
f"{issuer}/userinfo",
f"{issuer}/api/oidc/userinfo",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
userinfo = userinfo_resp.json()
from fastapi.responses import RedirectResponse
sub = userinfo.get("sub")
email = userinfo.get("email")
preferred_username = userinfo.get("preferred_username") or email
# Group gating: if an allowed group is configured, the user must be in it.
allowed_group = await _get_allowed_group(db)
if allowed_group:
groups = userinfo.get("groups") or []
if allowed_group not in groups:
return RedirectResponse(url="/login?auth_error=not_authorized")
# 1) Existing passkey identity → use it.
result = await db.execute(select(User).where(User.pocketid_sub == sub))
user = result.scalar_one_or_none()
# 2) No passkey identity yet, but an account with this email exists and is
# not already linked to a different passkey → link them (preserves data).
if not user and email:
result = await db.execute(select(User).where(User.email == email))
existing = result.scalar_one_or_none()
if existing and existing.pocketid_sub is None:
existing.pocketid_sub = sub
user = existing
# 3) Otherwise provision a new account with a collision-safe username.
if not user:
user = User(username=preferred_username, email=email, pocketid_sub=sub)
base = preferred_username or (email.split("@")[0] if email else "user")
username = await _unique_username(db, base)
# Only set email if no other account already claims it (unique column).
email_taken = False
if email:
dup = await db.execute(select(User).where(User.email == email))
email_taken = dup.scalar_one_or_none() is not None
user = User(username=username, email=None if email_taken else email, pocketid_sub=sub)
db.add(user)
await db.flush()
token = create_access_token({"sub": str(user.id)})
from fastapi.responses import RedirectResponse
return RedirectResponse(url=f"/?token={token}")
@@ -0,0 +1,160 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, GarminConnectConfig
router = APIRouter()
class GarminConfigIn(BaseModel):
email: str
password: Optional[str] = None # plaintext; encrypted before storage. None = keep existing.
sync_enabled: bool = True
sync_activities: bool = True
sync_wellness: bool = True
sync_lookback_days: int = 30 # days to look back on first sync; -1 = all-time
class GarminConfigOut(BaseModel):
email: str
sync_enabled: bool
sync_activities: bool
sync_wellness: bool
sync_lookback_days: int
last_sync_at: Optional[datetime]
last_sync_status: Optional[str]
connected: bool
class Config:
from_attributes = True
@router.get("/config", response_model=GarminConfigOut)
async def get_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if not cfg:
return GarminConfigOut(
email="", sync_enabled=False, sync_activities=True,
sync_wellness=True, sync_lookback_days=30,
last_sync_at=None, last_sync_status=None, connected=False,
)
return GarminConfigOut(
email=cfg.email,
sync_enabled=cfg.sync_enabled,
sync_activities=cfg.sync_activities,
sync_wellness=cfg.sync_wellness,
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
last_sync_at=cfg.last_sync_at,
last_sync_status=cfg.last_sync_status,
connected=True,
)
@router.put("/config", response_model=GarminConfigOut)
async def save_config(
body: GarminConfigIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Save Garmin Connect settings. If a password is provided, re-authenticates and
refreshes the stored OAuth token. If no password is provided, only updates the
non-credential settings (toggles, lookback days) without re-logging in.
"""
from app.services.garmin_connect_sync import encrypt_password, authenticate_garmin
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if body.password:
# Credentials update — test-login before saving
enc = encrypt_password(body.password)
try:
garmin, token_store = authenticate_garmin(body.email, enc, None)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Garmin login failed: {exc}")
if cfg:
cfg.email = body.email
cfg.password_enc = enc
cfg.token_store = token_store
cfg.last_sync_status = "Credentials updated"
else:
cfg = GarminConnectConfig(
user_id=current_user.id,
email=body.email,
password_enc=enc,
token_store=token_store,
last_sync_status="Connected",
)
db.add(cfg)
else:
# Settings-only update — password unchanged
if not cfg:
raise HTTPException(status_code=400, detail="No Garmin account connected — password required for first-time setup")
cfg.sync_enabled = body.sync_enabled
cfg.sync_activities = body.sync_activities
cfg.sync_wellness = body.sync_wellness
cfg.sync_lookback_days = body.sync_lookback_days
await db.commit()
await db.refresh(cfg)
return GarminConfigOut(
email=cfg.email,
sync_enabled=cfg.sync_enabled,
sync_activities=cfg.sync_activities,
sync_wellness=cfg.sync_wellness,
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
last_sync_at=cfg.last_sync_at,
last_sync_status=cfg.last_sync_status,
connected=True,
)
@router.delete("/config")
async def delete_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if cfg:
await db.delete(cfg)
await db.commit()
return {"status": "ok"}
@router.post("/trigger")
async def trigger_sync(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Enqueue an immediate Garmin Connect sync for this user."""
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if not cfg or not cfg.sync_enabled:
raise HTTPException(status_code=400, detail="Garmin Connect sync is not configured or disabled")
from app.workers.tasks import sync_garmin_connect_user
task = sync_garmin_connect_user.delay(current_user.id)
return {"task_id": task.id, "status": "queued"}
+52 -21
View File
@@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, date
from pydantic import BaseModel, model_validator
from typing import Optional, List, Any
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
@@ -44,6 +44,13 @@ class HealthMetricOut(BaseModel):
active_calories: Optional[float]
total_calories: Optional[float]
spo2_avg: Optional[float]
body_battery: Optional[Any] = None # {charged,drained,start_level,end_level} — values stripped
@model_validator(mode='after')
def _strip_bb_values(self):
if isinstance(self.body_battery, dict):
self.body_battery = {k: v for k, v in self.body_battery.items() if k != 'values'}
return self
class Config:
from_attributes = True
@@ -53,17 +60,20 @@ class HealthMetricOut(BaseModel):
async def list_health_metrics(
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
limit: int = Query(365, ge=1, le=1000),
limit: int = Query(365, ge=1, le=2000),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id)
if from_date:
q = q.where(HealthMetric.date >= from_date)
if to_date:
q = q.where(HealthMetric.date <= to_date)
q = q.order_by(desc(HealthMetric.date)).limit(limit)
if from_date:
from_date_naive = from_date.replace(tzinfo=None) if from_date.tzinfo else from_date
q = q.where(func.date(HealthMetric.date) >= from_date_naive.date())
if to_date:
to_date_naive = to_date.replace(tzinfo=None) if to_date.tzinfo else to_date
q = q.where(func.date(HealthMetric.date) <= to_date_naive.date())
q = q.order_by(desc(HealthMetric.date)).limit(limit)
result = await db.execute(q)
return result.scalars().all()
@@ -73,8 +83,6 @@ async def health_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Latest values + 30-day averages for dashboard widgets."""
# Latest record
latest_result = await db.execute(
select(HealthMetric)
.where(HealthMetric.user_id == current_user.id)
@@ -83,9 +91,7 @@ async def health_summary(
)
latest = latest_result.scalar_one_or_none()
# 30-day averages
from datetime import timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).date()
avg_result = await db.execute(
select(
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
@@ -97,7 +103,7 @@ async def health_summary(
func.avg(HealthMetric.weight_kg).label("avg_weight"),
).where(
HealthMetric.user_id == current_user.id,
HealthMetric.date >= cutoff,
func.date(HealthMetric.date) >= cutoff,
)
)
avgs = avg_result.one()
@@ -116,23 +122,48 @@ async def health_summary(
}
@router.get("/intraday")
async def intraday_health(
date: str = Query(..., description="YYYY-MM-DD"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return intraday heart rate series for a specific day."""
from datetime import date as _date
from fastapi import HTTPException
try:
metric_date = _date.fromisoformat(date)
except ValueError:
raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD")
result = await db.execute(
select(HealthMetric).where(
HealthMetric.user_id == current_user.id,
func.date(HealthMetric.date) == metric_date,
)
)
metric = result.scalar_one_or_none()
return {
"hr_values": metric.intraday_hr if metric else None,
"body_battery": metric.body_battery if metric else None,
"body_battery_hires": metric.body_battery_hires if metric else None,
"sleep_stages": metric.sleep_stages if metric else None,
}
@router.put("/manual")
async def add_manual_metric(
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Manually add or update a health metric for a given date."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
from fastapi import HTTPException
date_str = body.get("date")
if not date_str:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="date required")
metric_date = datetime.fromisoformat(date_str)
# Check for existing
existing = await db.execute(
select(HealthMetric).where(
HealthMetric.user_id == current_user.id,
@@ -153,4 +184,4 @@ async def add_manual_metric(
db.add(metric)
await db.commit()
return {"status": "ok"}
return {"status": "ok"}
@@ -19,6 +19,7 @@ class ProfileUpdate(BaseModel):
resting_heart_rate: Optional[int] = None
birth_year: Optional[int] = None
height_cm: Optional[float] = None
biological_sex: Optional[str] = None
class ProfileOut(BaseModel):
@@ -29,6 +30,7 @@ class ProfileOut(BaseModel):
resting_heart_rate: Optional[int]
birth_year: Optional[int]
height_cm: Optional[float]
biological_sex: Optional[str]
estimated_max_hr: Optional[int]
is_admin: bool
@@ -55,6 +57,7 @@ async def update_profile(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
old_max_hr = current_user.max_heart_rate
if body.max_heart_rate is not None:
if not (100 <= body.max_heart_rate <= 250):
raise HTTPException(400, "Max HR must be 100250")
@@ -71,9 +74,18 @@ async def update_profile(
if not (50 <= body.height_cm <= 300):
raise HTTPException(400, "Height must be 50300 cm")
current_user.height_cm = body.height_cm
if body.biological_sex is not None:
if body.biological_sex not in ('male', 'female', ''):
raise HTTPException(400, "biological_sex must be 'male' or 'female'")
current_user.biological_sex = body.biological_sex or None
await db.commit()
await db.refresh(current_user)
if body.max_heart_rate is not None and body.max_heart_rate != old_max_hr:
from app.workers.tasks import recalculate_hr_zones_for_user
recalculate_hr_zones_for_user.delay(current_user.id, body.max_heart_rate)
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
@@ -109,6 +121,7 @@ class PocketIDConfig(BaseModel):
issuer: Optional[str] = None
client_id: Optional[str] = None
client_secret: Optional[str] = None
allowed_group: Optional[str] = None
@router.get("/pocketid-config")
@@ -119,10 +132,12 @@ async def get_pocketid_config(current_user: User = Depends(get_current_user)):
# Show DB config if set, fall back to env
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
client_id = current_user.pocketid_client_id or settings.pocketid_client_id
allowed_group = current_user.pocketid_allowed_group or settings.pocketid_allowed_group
return {
"issuer": issuer or "",
"client_id": client_id or "",
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
"allowed_group": allowed_group or "",
"enabled": bool(issuer and client_id),
}
@@ -141,6 +156,8 @@ async def save_pocketid_config(
current_user.pocketid_client_id = body.client_id or None
if body.client_secret is not None:
current_user.pocketid_client_secret = body.client_secret or None
if body.allowed_group is not None:
current_user.pocketid_allowed_group = body.allowed_group.strip() or None
await db.commit()
return {"status": "ok"}
@@ -44,6 +44,36 @@ async def list_records(
return result.scalars().all()
@router.get("/routes")
async def route_records(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Fastest activity per named route (course records)."""
from sqlalchemy import text
rows = await db.execute(
text("""
SELECT DISTINCT ON (nr.id)
nr.id AS route_id,
nr.name AS route_name,
nr.sport_type,
nr.distance_m,
nr.reference_polyline,
a.id AS activity_id,
a.name AS activity_name,
a.duration_s,
a.start_time,
a.avg_speed_ms
FROM named_routes nr
JOIN activities a ON a.named_route_id = nr.id AND a.user_id = nr.user_id
WHERE nr.user_id = :uid AND a.duration_s IS NOT NULL
ORDER BY nr.id, a.duration_s ASC
"""),
{"uid": current_user.id},
)
return [dict(r._mapping) for r in rows]
@router.get("/history/{distance_label}")
async def record_history(
distance_label: str,
+343 -3
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from sqlalchemy import select, desc, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timedelta, timezone
@@ -36,6 +36,7 @@ class RouteOut(BaseModel):
distance_m: Optional[float]
auto_detected: Optional[bool]
created_at: datetime
activity_count: int = 0
class Config:
from_attributes = True
@@ -47,22 +48,51 @@ class SegmentOut(BaseModel):
start_distance_m: float
end_distance_m: float
description: Optional[str]
auto_generated: Optional[bool] = False
auto_generated_type: Optional[str] = None
class Config:
from_attributes = True
class AutoGenerateRequest(BaseModel):
type: str # "1km" | "turns" | "hills"
gradient_pct: float = 5.0
turn_angle_deg: float = 45.0
class SegmentTimeEntry(BaseModel):
activity_id: int
date: datetime
name: str
duration_s: float
@router.get("/", response_model=List[RouteOut])
async def list_routes(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Fetch routes with activity counts in one query
count_subq = (
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
.group_by(Activity.named_route_id)
.subquery()
)
result = await db.execute(
select(NamedRoute)
select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
.outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
.where(NamedRoute.user_id == current_user.id)
.order_by(desc(NamedRoute.created_at))
)
return result.scalars().all()
rows = result.all()
out = []
for route, cnt in rows:
d = {c.name: getattr(route, c.name) for c in route.__table__.columns}
d["activity_count"] = cnt
out.append(RouteOut(**d))
return out
@router.get("/recent-activities")
@@ -176,6 +206,61 @@ async def route_activities(
]
@router.post("/{route_id}/merge/{source_id}", response_model=RouteOut)
async def merge_routes(
route_id: int,
source_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Move all activities from source route into route_id, then delete source route."""
from sqlalchemy import update
target = (await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
source = (await db.execute(
select(NamedRoute).where(NamedRoute.id == source_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
if not target or not source:
raise HTTPException(status_code=404, detail="Route not found")
if route_id == source_id:
raise HTTPException(status_code=400, detail="Cannot merge a route with itself")
await db.execute(
update(Activity)
.where(Activity.named_route_id == source_id, Activity.user_id == current_user.id)
.values(named_route_id=route_id)
)
await db.delete(source)
await db.commit()
await db.refresh(target)
return target
@router.delete("/{route_id}")
async def delete_route(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from sqlalchemy import update as sa_update
route = (await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
# Unlink activities before deleting
await db.execute(
sa_update(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.values(named_route_id=None)
)
await db.delete(route)
await db.commit()
return {"status": "ok"}
@router.post("/{route_id}/assign-activity")
async def assign_activity_to_route(
route_id: int,
@@ -198,12 +283,23 @@ async def assign_activity_to_route(
return {"status": "ok"}
async def _get_owned_route(route_id: int, user_id: int, db: AsyncSession) -> NamedRoute:
result = await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == user_id)
)
route = result.scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
return route
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
async def list_segments(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
@@ -219,14 +315,258 @@ async def create_segment(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
segment = RouteSegment(
route_id=route_id,
name=body.name,
start_distance_m=body.start_distance_m,
end_distance_m=body.end_distance_m,
description=body.description,
auto_generated=False,
)
db.add(segment)
await db.commit()
await db.refresh(segment)
return segment
@router.delete("/{route_id}/segments/{segment_id}", status_code=204)
async def delete_segment(
route_id: int,
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
result = await db.execute(
select(RouteSegment).where(
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
)
)
seg = result.scalar_one_or_none()
if not seg:
raise HTTPException(status_code=404, detail="Segment not found")
await db.delete(seg)
await db.commit()
@router.post("/{route_id}/segments/auto", response_model=List[SegmentOut])
async def auto_generate_segments(
route_id: int,
body: AutoGenerateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Auto-generate segments: 1km splits, turns, or hills."""
from app.services.route_matcher import (
generate_1km_segments, generate_turn_segments, generate_hill_segments,
)
from sqlalchemy import delete as sql_delete
route = await _get_owned_route(route_id, current_user.id, db)
if body.type not in ("1km", "turns", "hills"):
raise HTTPException(status_code=400, detail="type must be '1km', 'turns', or 'hills'")
# Clear only auto-generated segments of the same type so other auto types are preserved
await db.execute(
sql_delete(RouteSegment).where(
RouteSegment.route_id == route_id,
RouteSegment.auto_generated == True,
RouteSegment.auto_generated_type == body.type,
)
)
raw_segments: list[tuple[str, float, float]] = []
if body.type == "1km":
if not route.distance_m:
raise HTTPException(status_code=400, detail="Route has no distance recorded")
raw_segments = generate_1km_segments(route.reference_polyline or "", route.distance_m)
elif body.type == "turns":
if not route.reference_polyline:
raise HTTPException(status_code=400, detail="Route has no polyline")
raw_segments = generate_turn_segments(route.reference_polyline, body.turn_angle_deg)
elif body.type == "hills":
if not route.reference_polyline:
raise HTTPException(status_code=400, detail="Route has no polyline")
# Find most recent matched activity for elevation data
act_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(1)
)
act = act_result.scalar_one_or_none()
if not act:
raise HTTPException(status_code=400, detail="No matched activities found for elevation data")
from app.models.user import ActivityDataPoint
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == act.id)
.order_by(ActivityDataPoint.timestamp)
)
dps = dp_result.scalars().all()
dp_list = [{"distance_m": p.distance_m, "altitude_m": p.altitude_m} for p in dps]
raw_segments = generate_hill_segments(dp_list, body.gradient_pct)
new_segments = []
for name, start_m, end_m in raw_segments:
seg = RouteSegment(
route_id=route_id,
name=name,
start_distance_m=start_m,
end_distance_m=end_m,
auto_generated=True,
auto_generated_type=body.type,
)
db.add(seg)
new_segments.append(seg)
await db.commit()
for seg in new_segments:
await db.refresh(seg)
return new_segments
class SegmentBestOut(BaseModel):
segment_id: int
name: str
start_distance_m: float
end_distance_m: float
auto_generated: bool
best_s: Optional[float]
best_activity_id: Optional[int]
count: int
@router.get("/{route_id}/segment-bests", response_model=List[SegmentBestOut])
async def get_segment_bests(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return best time per segment across all matched activities for a route."""
from app.services.route_matcher import find_segment_times
from app.models.user import ActivityDataPoint
from collections import defaultdict
await _get_owned_route(route_id, current_user.id, db)
segs_result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
.order_by(RouteSegment.start_distance_m)
)
segments = segs_result.scalars().all()
if not segments:
return []
acts_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(20)
)
activities = acts_result.scalars().all()
if not activities:
return [
SegmentBestOut(
segment_id=s.id, name=s.name,
start_distance_m=s.start_distance_m, end_distance_m=s.end_distance_m,
auto_generated=bool(s.auto_generated), best_s=None, best_activity_id=None, count=0,
)
for s in segments
]
act_ids = [a.id for a in activities]
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id.in_(act_ids))
.order_by(ActivityDataPoint.activity_id, ActivityDataPoint.timestamp)
)
all_dps = dp_result.scalars().all()
# Group data points by activity_id
dp_by_act = defaultdict(list)
for dp in all_dps:
if dp.distance_m is not None:
dp_by_act[dp.activity_id].append({"distance_m": dp.distance_m, "timestamp": dp.timestamp})
bests = []
for seg in segments:
best_s = None
best_act_id = None
count = 0
for act_id in act_ids:
dp_list = dp_by_act.get(act_id, [])
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
if duration is not None:
count += 1
if best_s is None or duration < best_s:
best_s = duration
best_act_id = act_id
bests.append(SegmentBestOut(
segment_id=seg.id, name=seg.name,
start_distance_m=seg.start_distance_m, end_distance_m=seg.end_distance_m,
auto_generated=bool(seg.auto_generated),
best_s=best_s, best_activity_id=best_act_id, count=count,
))
return bests
@router.get("/{route_id}/segments/{segment_id}/times", response_model=List[SegmentTimeEntry])
async def get_segment_times(
route_id: int,
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return the last 10 times this segment was traversed across matched activities."""
from app.services.route_matcher import find_segment_times
from app.models.user import ActivityDataPoint
await _get_owned_route(route_id, current_user.id, db)
seg_result = await db.execute(
select(RouteSegment).where(
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
)
)
seg = seg_result.scalar_one_or_none()
if not seg:
raise HTTPException(status_code=404, detail="Segment not found")
acts_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(10)
)
activities = acts_result.scalars().all()
times = []
for act in activities:
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == act.id)
.order_by(ActivityDataPoint.timestamp)
)
dps = dp_result.scalars().all()
dp_list = [
{"distance_m": p.distance_m, "timestamp": p.timestamp}
for p in dps
if p.distance_m is not None
]
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
if duration:
times.append(SegmentTimeEntry(
activity_id=act.id,
date=act.start_time,
name=act.name,
duration_s=duration,
))
return times
+18 -1
View File
@@ -75,6 +75,22 @@ async def upload_garmin_export(
fit_path = extract_dir / name
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
task_ids.append(task.id)
elif lower.endswith(".zip"):
# Garmin exports nest activity FIT files inside sub-zips
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
nested_zip_path = extract_dir / name
nested_extract = nested_zip_path.parent / nested_zip_path.stem
nested_extract.mkdir(exist_ok=True)
try:
with zipfile.ZipFile(nested_zip_path) as nzf:
nzf.extractall(nested_extract)
for nested_name in nzf.namelist():
if nested_name.lower().endswith(".fit"):
fit_path = nested_extract / nested_name
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
task_ids.append(task.id)
except zipfile.BadZipFile:
pass
# Queue health/wellness data extraction
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
@@ -82,7 +98,7 @@ async def upload_garmin_export(
return {
"status": "queued",
"activity_tasks": len(task_ids),
"health_task": health_task.id,
"task_id": health_task.id,
}
@@ -116,6 +132,7 @@ async def upload_strava_export(
return {
"status": "queued",
"activity_tasks": len(task_ids),
"task_id": task_ids[-1] if task_ids else None,
}
+142
View File
@@ -0,0 +1,142 @@
"""
Admin-only user management: list provisioned users, promote/demote admin,
and delete a user together with all of their data.
New users are normally provisioned just-in-time on first PocketID login
(see app/api/auth.py). This router is the in-app surface for managing them.
"""
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.core.config import settings
from app.models.user import (
User, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
RouteSegment, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
)
router = APIRouter()
def _require_admin(current_user: User):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
async def _admin_count(db: AsyncSession) -> int:
result = await db.execute(select(func.count()).select_from(User).where(User.is_admin == True))
return result.scalar_one()
class UserOut(BaseModel):
id: int
username: str
email: Optional[str]
is_admin: bool
has_passkey: bool
activity_count: int
created_at: Optional[str]
class AdminUpdate(BaseModel):
is_admin: bool
@router.get("/")
async def list_users(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
# activity counts per user in one grouped query
counts = dict(
(await db.execute(
select(Activity.user_id, func.count(Activity.id)).group_by(Activity.user_id)
)).all()
)
result = await db.execute(select(User).order_by(User.id))
users = result.scalars().all()
return [
UserOut(
id=u.id,
username=u.username,
email=u.email,
is_admin=u.is_admin,
has_passkey=u.pocketid_sub is not None,
activity_count=counts.get(u.id, 0),
created_at=u.created_at.isoformat() if u.created_at else None,
)
for u in users
]
@router.patch("/{user_id}")
async def set_admin(
user_id: int,
body: AdminUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
if user_id == current_user.id:
raise HTTPException(400, "You cannot change your own admin status")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
# Demoting the last remaining admin would lock everyone out.
if user.is_admin and not body.is_admin and await _admin_count(db) <= 1:
raise HTTPException(400, "Cannot demote the last admin")
user.is_admin = body.is_admin
await db.commit()
return {"status": "ok", "is_admin": user.is_admin}
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
if user_id == current_user.id:
raise HTTPException(400, "You cannot delete your own account")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
if user.is_admin and await _admin_count(db) <= 1:
raise HTTPException(400, "Cannot delete the last admin")
# Ordered deletes: PersonalRecord and the activity/route child tables have no
# cascade path from User, so remove them before the parents to avoid FK errors.
activity_ids = select(Activity.id).where(Activity.user_id == user_id)
route_ids = select(NamedRoute.id).where(NamedRoute.user_id == user_id)
await db.execute(delete(PersonalRecord).where(PersonalRecord.user_id == user_id))
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids)))
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_ids)))
await db.execute(delete(RouteSegment).where(RouteSegment.route_id.in_(route_ids)))
await db.execute(delete(Activity).where(Activity.user_id == user_id))
await db.execute(delete(NamedRoute).where(NamedRoute.user_id == user_id))
await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id))
await db.execute(delete(WeightLog).where(WeightLog.user_id == user_id))
await db.execute(delete(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id))
await db.execute(delete(User).where(User.id == user_id))
await db.commit()
# Remove the user's uploaded files from disk (best-effort).
shutil.rmtree(Path(settings.file_store_path) / str(user_id), ignore_errors=True)
return {"status": "ok"}