0e4bc7b444
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>
271 lines
8.3 KiB
Python
271 lines
8.3 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, desc, delete
|
|
from pydantic import BaseModel
|
|
from typing import Optional, List
|
|
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, Activity, ActivityDataPoint, ActivityLap
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class ActivitySummary(BaseModel):
|
|
id: int
|
|
name: str
|
|
sport_type: str
|
|
start_time: datetime
|
|
distance_m: Optional[float]
|
|
duration_s: Optional[float]
|
|
elevation_gain_m: Optional[float]
|
|
avg_heart_rate: Optional[float]
|
|
avg_cadence: Optional[float]
|
|
avg_speed_ms: Optional[float]
|
|
calories: Optional[float]
|
|
polyline: Optional[str]
|
|
bounding_box: Optional[dict]
|
|
hr_zones: Optional[dict]
|
|
named_route_id: Optional[int]
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class ActivityDetail(ActivitySummary):
|
|
end_time: Optional[datetime]
|
|
elevation_loss_m: Optional[float]
|
|
max_heart_rate: Optional[float]
|
|
avg_power: Optional[float]
|
|
normalized_power: Optional[float]
|
|
max_speed_ms: Optional[float]
|
|
avg_temperature_c: Optional[float]
|
|
training_stress_score: Optional[float]
|
|
vo2max_estimate: Optional[float]
|
|
|
|
|
|
class DataPointOut(BaseModel):
|
|
timestamp: Optional[datetime]
|
|
latitude: Optional[float]
|
|
longitude: Optional[float]
|
|
altitude_m: Optional[float]
|
|
heart_rate: Optional[float]
|
|
cadence: Optional[float]
|
|
speed_ms: Optional[float]
|
|
power: Optional[float]
|
|
temperature_c: Optional[float]
|
|
distance_m: Optional[float]
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class LapOut(BaseModel):
|
|
lap_number: int
|
|
start_time: Optional[datetime]
|
|
duration_s: Optional[float]
|
|
distance_m: Optional[float]
|
|
avg_heart_rate: Optional[float]
|
|
avg_cadence: Optional[float]
|
|
avg_speed_ms: Optional[float]
|
|
avg_power: Optional[float]
|
|
|
|
class Config:
|
|
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),
|
|
per_page: int = Query(20, ge=1, le=100),
|
|
sport_type: Optional[str] = None,
|
|
from_date: Optional[datetime] = None,
|
|
to_date: Optional[datetime] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
q = select(Activity).where(Activity.user_id == current_user.id)
|
|
|
|
if sport_type:
|
|
q = q.where(Activity.sport_type == sport_type)
|
|
if from_date:
|
|
q = q.where(Activity.start_time >= from_date)
|
|
if to_date:
|
|
q = q.where(Activity.start_time <= to_date)
|
|
|
|
q = q.order_by(desc(Activity.start_time))
|
|
q = q.offset((page - 1) * per_page).limit(per_page)
|
|
|
|
result = await db.execute(q)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.get("/{activity_id}", response_model=ActivityDetail)
|
|
async def get_activity(
|
|
activity_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
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")
|
|
return activity
|
|
|
|
|
|
@router.get("/{activity_id}/data-points", response_model=List[DataPointOut])
|
|
async def get_data_points(
|
|
activity_id: int,
|
|
downsample: int = Query(0, ge=0, description="Return every Nth point; 0 = all"),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
act = await db.execute(
|
|
select(Activity).where(
|
|
Activity.id == activity_id,
|
|
Activity.user_id == current_user.id,
|
|
)
|
|
)
|
|
if not act.scalar_one_or_none():
|
|
raise HTTPException(status_code=404, detail="Activity not found")
|
|
|
|
q = select(ActivityDataPoint).where(
|
|
ActivityDataPoint.activity_id == activity_id
|
|
).order_by(ActivityDataPoint.timestamp)
|
|
|
|
result = await db.execute(q)
|
|
points = result.scalars().all()
|
|
|
|
if downsample > 1:
|
|
points = points[::downsample]
|
|
|
|
return points
|
|
|
|
|
|
@router.get("/{activity_id}/laps", response_model=List[LapOut])
|
|
async def get_laps(
|
|
activity_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
act = await db.execute(
|
|
select(Activity).where(
|
|
Activity.id == activity_id,
|
|
Activity.user_id == current_user.id,
|
|
)
|
|
)
|
|
if not act.scalar_one_or_none():
|
|
raise HTTPException(status_code=404, detail="Activity not found")
|
|
|
|
result = await db.execute(
|
|
select(ActivityLap)
|
|
.where(ActivityLap.activity_id == activity_id)
|
|
.order_by(ActivityLap.lap_number)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.patch("/{activity_id}/name")
|
|
async def rename_activity(
|
|
activity_id: int,
|
|
body: dict,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
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")
|
|
|
|
activity.name = body.get("name", activity.name)
|
|
await db.commit()
|
|
return {"id": activity_id, "name": activity.name}
|
|
|
|
|
|
@router.delete("/{activity_id}", status_code=204)
|
|
async def delete_activity(
|
|
activity_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
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")
|
|
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"} |