234 lines
9.3 KiB
Python
234 lines
9.3 KiB
Python
from sqlalchemy import (
|
|
Column, Integer, String, Float, DateTime, Boolean,
|
|
ForeignKey, Text, JSON, Index, UniqueConstraint
|
|
)
|
|
from sqlalchemy.orm import relationship
|
|
from datetime import datetime, timezone
|
|
from app.core.database import Base
|
|
|
|
|
|
def now_utc():
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
username = Column(String(64), unique=True, nullable=False, index=True)
|
|
email = Column(String(256), unique=True, nullable=True)
|
|
hashed_password = Column(String(256), nullable=True) # null = OIDC-only user
|
|
is_admin = Column(Boolean, default=False)
|
|
pocketid_sub = Column(String(256), unique=True, nullable=True)
|
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
|
|
|
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
|
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
|
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
|
|
|
|
|
class Activity(Base):
|
|
__tablename__ = "activities"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
|
|
|
# Core fields
|
|
name = Column(String(256), nullable=False)
|
|
sport_type = Column(String(64), nullable=False) # running, cycling, swimming, etc.
|
|
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
|
end_time = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
# Metrics summary (cached aggregates)
|
|
distance_m = Column(Float, nullable=True) # metres
|
|
duration_s = Column(Float, nullable=True) # seconds
|
|
elevation_gain_m = Column(Float, nullable=True)
|
|
elevation_loss_m = Column(Float, nullable=True)
|
|
avg_heart_rate = Column(Float, nullable=True)
|
|
max_heart_rate = Column(Float, nullable=True)
|
|
avg_cadence = Column(Float, nullable=True)
|
|
avg_power = Column(Float, nullable=True)
|
|
normalized_power = Column(Float, nullable=True)
|
|
avg_speed_ms = Column(Float, nullable=True)
|
|
max_speed_ms = Column(Float, nullable=True)
|
|
avg_temperature_c = Column(Float, nullable=True)
|
|
calories = Column(Float, nullable=True)
|
|
training_stress_score = Column(Float, nullable=True)
|
|
vo2max_estimate = Column(Float, nullable=True)
|
|
|
|
# Route reference
|
|
named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True)
|
|
|
|
# Raw GPS track (encoded polyline for quick map render)
|
|
polyline = Column(Text, nullable=True)
|
|
bounding_box = Column(JSON, nullable=True) # {min_lat, max_lat, min_lon, max_lon}
|
|
|
|
# Source file info
|
|
source_file = Column(String(512), nullable=True)
|
|
source_type = Column(String(32), nullable=True) # fit, gpx, strava_json
|
|
garmin_activity_id = Column(String(64), nullable=True, unique=True)
|
|
strava_activity_id = Column(String(64), nullable=True, unique=True)
|
|
|
|
# HR zones (% of time in each zone)
|
|
hr_zones = Column(JSON, nullable=True) # {z1: pct, z2: pct, ...}
|
|
|
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
|
|
|
user = relationship("User", back_populates="activities")
|
|
data_points = relationship("ActivityDataPoint", back_populates="activity", cascade="all, delete-orphan")
|
|
named_route = relationship("NamedRoute", back_populates="activities")
|
|
laps = relationship("ActivityLap", back_populates="activity", cascade="all, delete-orphan")
|
|
|
|
|
|
class ActivityDataPoint(Base):
|
|
"""
|
|
TimescaleDB hypertable - one row per second of activity data.
|
|
After creation, converted to hypertable in migration:
|
|
SELECT create_hypertable('activity_data_points', 'timestamp');
|
|
"""
|
|
__tablename__ = "activity_data_points"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True)
|
|
timestamp = Column(DateTime(timezone=True), nullable=False)
|
|
latitude = Column(Float, nullable=True)
|
|
longitude = Column(Float, nullable=True)
|
|
altitude_m = Column(Float, nullable=True)
|
|
heart_rate = Column(Float, nullable=True)
|
|
cadence = Column(Float, nullable=True)
|
|
speed_ms = Column(Float, nullable=True)
|
|
power = Column(Float, nullable=True)
|
|
temperature_c = Column(Float, nullable=True)
|
|
distance_m = Column(Float, nullable=True) # cumulative distance
|
|
|
|
__table_args__ = (
|
|
Index("ix_adp_activity_time", "activity_id", "timestamp"),
|
|
)
|
|
|
|
activity = relationship("Activity", back_populates="data_points")
|
|
|
|
|
|
class ActivityLap(Base):
|
|
__tablename__ = "activity_laps"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True)
|
|
lap_number = Column(Integer, nullable=False)
|
|
start_time = Column(DateTime(timezone=True), nullable=True)
|
|
duration_s = Column(Float, nullable=True)
|
|
distance_m = Column(Float, nullable=True)
|
|
avg_heart_rate = Column(Float, nullable=True)
|
|
avg_cadence = Column(Float, nullable=True)
|
|
avg_speed_ms = Column(Float, nullable=True)
|
|
avg_power = Column(Float, nullable=True)
|
|
|
|
activity = relationship("Activity", back_populates="laps")
|
|
|
|
|
|
class NamedRoute(Base):
|
|
__tablename__ = "named_routes"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
|
name = Column(String(256), nullable=False)
|
|
description = Column(Text, nullable=True)
|
|
sport_type = Column(String(64), nullable=True)
|
|
reference_polyline = Column(Text, nullable=True) # canonical route polyline
|
|
bounding_box = Column(JSON, nullable=True)
|
|
distance_m = Column(Float, nullable=True)
|
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
|
|
|
user = relationship("User", back_populates="named_routes")
|
|
activities = relationship("Activity", back_populates="named_route")
|
|
segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan")
|
|
|
|
|
|
class RouteSegment(Base):
|
|
"""Named sections within a route for targeted comparisons (e.g. 'The big hill')"""
|
|
__tablename__ = "route_segments"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True)
|
|
name = Column(String(256), nullable=False)
|
|
start_distance_m = Column(Float, nullable=False) # distance into route where segment starts
|
|
end_distance_m = Column(Float, nullable=False)
|
|
description = Column(Text, nullable=True)
|
|
|
|
route = relationship("NamedRoute", back_populates="segments")
|
|
|
|
|
|
class PersonalRecord(Base):
|
|
__tablename__ = "personal_records"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
|
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False)
|
|
sport_type = Column(String(64), nullable=False)
|
|
distance_m = Column(Float, nullable=False) # e.g. 1000, 1609, 5000, 10000, 42195
|
|
distance_label = Column(String(32), nullable=False) # e.g. "1k", "1 mile", "5k"
|
|
duration_s = Column(Float, nullable=False)
|
|
achieved_at = Column(DateTime(timezone=True), nullable=False)
|
|
is_current_record = Column(Boolean, default=True)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("user_id", "sport_type", "distance_m", "is_current_record",
|
|
name="uq_pr_current"),
|
|
)
|
|
|
|
|
|
class HealthMetric(Base):
|
|
"""Daily health summary metrics from Garmin Connect / FIT wellness data"""
|
|
__tablename__ = "health_metrics"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
|
date = Column(DateTime(timezone=True), nullable=False)
|
|
|
|
# Heart rate
|
|
resting_hr = Column(Float, nullable=True)
|
|
max_hr_day = Column(Float, nullable=True)
|
|
avg_hr_day = Column(Float, nullable=True)
|
|
|
|
# HRV
|
|
hrv_status = Column(String(32), nullable=True) # balanced, unbalanced, etc.
|
|
hrv_nightly_avg = Column(Float, nullable=True)
|
|
hrv_5min_high = Column(Float, nullable=True)
|
|
hrv_5min_low = Column(Float, nullable=True)
|
|
|
|
# Sleep
|
|
sleep_duration_s = Column(Float, nullable=True)
|
|
sleep_deep_s = Column(Float, nullable=True)
|
|
sleep_light_s = Column(Float, nullable=True)
|
|
sleep_rem_s = Column(Float, nullable=True)
|
|
sleep_awake_s = Column(Float, nullable=True)
|
|
sleep_score = Column(Float, nullable=True)
|
|
sleep_start = Column(DateTime(timezone=True), nullable=True)
|
|
sleep_end = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
# Body composition
|
|
weight_kg = Column(Float, nullable=True)
|
|
bmi = Column(Float, nullable=True)
|
|
body_fat_pct = Column(Float, nullable=True)
|
|
muscle_mass_kg = Column(Float, nullable=True)
|
|
|
|
# Fitness
|
|
vo2max = Column(Float, nullable=True)
|
|
fitness_age = Column(Integer, nullable=True)
|
|
training_load = Column(Float, nullable=True)
|
|
recovery_time_h = Column(Float, nullable=True)
|
|
|
|
# Stress & activity
|
|
avg_stress = Column(Float, nullable=True)
|
|
steps = Column(Integer, nullable=True)
|
|
floors_climbed = Column(Integer, nullable=True)
|
|
active_calories = Column(Float, nullable=True)
|
|
total_calories = Column(Float, nullable=True)
|
|
spo2_avg = Column(Float, nullable=True)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
|
Index("ix_health_user_date", "user_id", "date"),
|
|
)
|
|
|
|
user = relationship("User", back_populates="health_metrics")
|