Initial Commit

This commit is contained in:
2026-06-06 13:23:33 +01:00
commit 1a0d45dd67
58 changed files with 5268 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
"""
Background tasks: activity ingestion, route matching, PR calculation.
"""
import asyncio
from celery import Celery
from app.core.config import settings
celery_app = Celery(
"fittracker",
broker=settings.redis_url,
backend=settings.redis_url,
)
celery_app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="UTC",
enable_utc=True,
task_track_started=True,
worker_prefetch_multiplier=1,
)
def run_async(coro):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
@celery_app.task(bind=True, name="process_activity_file")
def process_activity_file(self, file_path: str, user_id: int, source_type: str):
"""Parse a FIT/GPX file and insert activity + data points into DB."""
from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones
from app.services.route_matcher import compute_best_splits, routes_are_similar
from app.core.database import AsyncSessionLocal
from app.models.user import Activity, ActivityDataPoint, ActivityLap, PersonalRecord, HealthMetric
from sqlalchemy import select
from datetime import datetime, timezone
self.update_state(state="PROGRESS", meta={"step": "parsing"})
try:
if source_type == "fit" or file_path.endswith(".fit"):
parsed = parse_fit_file(file_path)
else:
parsed = parse_gpx_file(file_path)
except Exception as e:
raise self.retry(exc=e, countdown=10, max_retries=3)
async def _insert():
async with AsyncSessionLocal() as db:
# Check for duplicate
if parsed.get("garmin_activity_id"):
existing = await db.execute(
select(Activity).where(
Activity.garmin_activity_id == parsed["garmin_activity_id"]
)
)
if existing.scalar_one_or_none():
return None
# HR zones
hr_zones = calculate_hr_zones(
parsed.get("data_points", []),
parsed.get("max_heart_rate") or 190
)
# Create activity
start_time = datetime.fromisoformat(parsed["start_time"]) if parsed.get("start_time") else None
activity = Activity(
user_id=user_id,
name=parsed["name"],
sport_type=parsed["sport_type"],
start_time=start_time,
distance_m=parsed.get("distance_m"),
duration_s=parsed.get("duration_s"),
elevation_gain_m=parsed.get("elevation_gain_m"),
elevation_loss_m=parsed.get("elevation_loss_m"),
avg_heart_rate=parsed.get("avg_heart_rate"),
max_heart_rate=parsed.get("max_heart_rate"),
avg_cadence=parsed.get("avg_cadence"),
avg_power=parsed.get("avg_power"),
normalized_power=parsed.get("normalized_power"),
avg_speed_ms=parsed.get("avg_speed_ms"),
max_speed_ms=parsed.get("max_speed_ms"),
avg_temperature_c=parsed.get("avg_temperature_c"),
calories=parsed.get("calories"),
training_stress_score=parsed.get("training_stress_score"),
polyline=parsed.get("polyline"),
bounding_box=parsed.get("bounding_box"),
source_file=file_path,
source_type=parsed.get("source_type"),
hr_zones=hr_zones,
)
db.add(activity)
await db.flush()
# Insert data points in batches
points = parsed.get("data_points", [])
batch_size = 500
for i in range(0, len(points), batch_size):
batch = points[i:i+batch_size]
db.add_all([
ActivityDataPoint(
activity_id=activity.id,
timestamp=datetime.fromisoformat(p["timestamp"]) if p.get("timestamp") else None,
latitude=p.get("latitude"),
longitude=p.get("longitude"),
altitude_m=p.get("altitude_m"),
heart_rate=p.get("heart_rate"),
cadence=p.get("cadence"),
speed_ms=p.get("speed_ms"),
power=p.get("power"),
temperature_c=p.get("temperature_c"),
distance_m=p.get("distance_m"),
)
for p in batch
])
# Insert laps
for lap in parsed.get("laps", []):
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
db.add(ActivityLap(
activity_id=activity.id,
lap_number=lap["lap_number"],
start_time=ls,
duration_s=lap.get("duration_s"),
distance_m=lap.get("distance_m"),
avg_heart_rate=lap.get("avg_heart_rate"),
avg_cadence=lap.get("avg_cadence"),
avg_speed_ms=lap.get("avg_speed_ms"),
avg_power=lap.get("avg_power"),
))
await db.commit()
return activity.id
activity_id = run_async(_insert())
if activity_id:
# Queue PR calculation
compute_personal_records.delay(activity_id, user_id, parsed)
return {"activity_id": activity_id, "status": "ok"}
@celery_app.task(name="compute_personal_records")
def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
"""Calculate personal records for standard distances from this activity."""
from app.services.route_matcher import compute_best_splits, STANDARD_DISTANCES
from app.core.database import AsyncSessionLocal
from app.models.user import PersonalRecord
from sqlalchemy import select
from datetime import datetime, timezone
data_points = parsed.get("data_points", [])
total_dist = parsed.get("distance_m", 0) or 0
sport = parsed.get("sport_type", "running")
start_time_str = parsed.get("start_time")
start_time = datetime.fromisoformat(start_time_str) if start_time_str else datetime.now(timezone.utc)
best_splits = compute_best_splits(data_points, total_dist)
async def _save():
async with AsyncSessionLocal() as db:
for label, duration_s in best_splits.items():
dist_m = next((d for d, l in STANDARD_DISTANCES if l == label), None)
if dist_m is None:
continue
# Check existing record
existing = await db.execute(
select(PersonalRecord).where(
PersonalRecord.user_id == user_id,
PersonalRecord.sport_type == sport,
PersonalRecord.distance_m == dist_m,
PersonalRecord.is_current_record == True,
)
)
current = existing.scalar_one_or_none()
if current is None or duration_s < current.duration_s:
if current:
current.is_current_record = False
db.add(PersonalRecord(
user_id=user_id,
activity_id=activity_id,
sport_type=sport,
distance_m=dist_m,
distance_label=label,
duration_s=duration_s,
achieved_at=start_time,
is_current_record=True,
))
await db.commit()
run_async(_save())
@celery_app.task(name="process_garmin_health_zip")
def process_garmin_health_zip(zip_path: str, user_id: int):
"""
Process a Garmin Connect data export zip.
Extracts wellness/sleep/HRV CSV files and inserts health metrics.
"""
import zipfile
import json
import csv
from pathlib import Path
from app.core.database import AsyncSessionLocal
from app.models.user import HealthMetric
from sqlalchemy.dialects.postgresql import insert
from datetime import datetime, timezone
async def _process():
async with AsyncSessionLocal() as db:
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
# Parse daily summary JSON files from Garmin export
for name in names:
if "DailyMetrics" in name and name.endswith(".json"):
with zf.open(name) as f:
try:
data = json.load(f)
except Exception:
continue
date_str = data.get("calendarDate") or data.get("date")
if not date_str:
continue
try:
date = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
except ValueError:
continue
metric = HealthMetric(
user_id=user_id,
date=date,
resting_hr=data.get("restingHeartRate"),
steps=data.get("totalSteps"),
floors_climbed=data.get("floorsAscended"),
active_calories=data.get("activeKilocalories"),
total_calories=data.get("totalKilocalories"),
avg_stress=data.get("averageStressLevel"),
spo2_avg=data.get("avgSpo2"),
)
db.add(metric)
await db.commit()
run_async(_process())