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>
152 lines
5.4 KiB
Python
152 lines
5.4 KiB
Python
import os
|
|
import shutil
|
|
import zipfile
|
|
from pathlib import Path
|
|
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
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
|
|
from app.workers.tasks import process_activity_file, process_garmin_health_zip
|
|
|
|
router = APIRouter()
|
|
|
|
ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"}
|
|
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
|
|
|
|
|
|
def save_upload(upload: UploadFile, dest_dir: Path) -> Path:
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
dest = dest_dir / upload.filename
|
|
with open(dest, "wb") as f:
|
|
shutil.copyfileobj(upload.file, f)
|
|
return dest
|
|
|
|
|
|
@router.post("/activity")
|
|
async def upload_activity(
|
|
file: UploadFile = File(...),
|
|
background_tasks: BackgroundTasks = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Upload a single .fit or .gpx activity file."""
|
|
suffix = Path(file.filename).suffix.lower()
|
|
if suffix not in {".fit", ".gpx"}:
|
|
raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported")
|
|
|
|
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities"
|
|
dest = save_upload(file, dest_dir)
|
|
|
|
# Queue processing
|
|
task = process_activity_file.delay(str(dest), current_user.id, suffix[1:])
|
|
|
|
return {"task_id": task.id, "status": "queued", "filename": file.filename}
|
|
|
|
|
|
@router.post("/garmin-export")
|
|
async def upload_garmin_export(
|
|
file: UploadFile = File(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Upload a full Garmin Connect data export ZIP.
|
|
Processes all FIT files for activities + wellness data.
|
|
"""
|
|
if not file.filename.endswith(".zip"):
|
|
raise HTTPException(status_code=400, detail="Please upload a .zip Garmin export")
|
|
|
|
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
|
|
dest = save_upload(file, dest_dir)
|
|
|
|
# Extract and queue all FIT files
|
|
extract_dir = dest_dir / f"garmin_{dest.stem}"
|
|
extract_dir.mkdir(exist_ok=True)
|
|
|
|
task_ids = []
|
|
with zipfile.ZipFile(dest) as zf:
|
|
zf.extractall(extract_dir)
|
|
for name in zf.namelist():
|
|
lower = name.lower()
|
|
if lower.endswith(".fit"):
|
|
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)
|
|
|
|
return {
|
|
"status": "queued",
|
|
"activity_tasks": len(task_ids),
|
|
"task_id": health_task.id,
|
|
}
|
|
|
|
|
|
@router.post("/strava-export")
|
|
async def upload_strava_export(
|
|
file: UploadFile = File(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Upload a Strava bulk export ZIP (contains activities/ folder with GPX/FIT files)."""
|
|
if not file.filename.endswith(".zip"):
|
|
raise HTTPException(status_code=400, detail="Please upload a .zip Strava export")
|
|
|
|
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
|
|
dest = save_upload(file, dest_dir)
|
|
|
|
extract_dir = dest_dir / f"strava_{dest.stem}"
|
|
extract_dir.mkdir(exist_ok=True)
|
|
|
|
task_ids = []
|
|
with zipfile.ZipFile(dest) as zf:
|
|
zf.extractall(extract_dir)
|
|
for name in zf.namelist():
|
|
lower = name.lower()
|
|
if lower.endswith(".fit") or lower.endswith(".gpx"):
|
|
file_path = extract_dir / name
|
|
ext = Path(name).suffix[1:]
|
|
task = process_activity_file.delay(str(file_path), current_user.id, ext)
|
|
task_ids.append(task.id)
|
|
|
|
return {
|
|
"status": "queued",
|
|
"activity_tasks": len(task_ids),
|
|
"task_id": task_ids[-1] if task_ids else None,
|
|
}
|
|
|
|
|
|
@router.get("/task/{task_id}")
|
|
async def check_task_status(
|
|
task_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Check the status of an upload processing task."""
|
|
from app.workers.celery_app import celery_app
|
|
result = celery_app.AsyncResult(task_id)
|
|
return {
|
|
"task_id": task_id,
|
|
"status": result.status,
|
|
"result": result.result if result.ready() else None,
|
|
}
|