Files
MileVault/backend/app/models/user.py
T
owain 8d304545a3
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s
Health page: VO2 max gauge, layout improvements, 3Y/5Y trends, biological sex profile
- Add biological_sex field to User model, profile API, and ProfilePage toggle (male/female) — used to select the correct ACSM VO2 max threshold table
- Replace simple VO2 max number in daily snapshot with a colour-coded SVG radial gauge (Very Poor=red, Poor=orange, Fair=green, Good=blue, Excellent=purple) driven by sex- and age-appropriate thresholds
- Shrink Sleep widget to half-width, expand Heart & HRV to half-width; reorganise Heart & HRV internals into a 2×2 grid to reduce vertical height
- Add connectNulls + showDots to VO2 Max trend chart so sparse readings connect with a continuous line
- Add 3Y and 5Y range options to the Trends selector; increase allDays limit to 2000 for full 5yr snapshot navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 23:49:01 +01:00

260 lines
12 KiB
Python

from sqlalchemy import (
Column, Integer, String, Float, DateTime, Boolean,
ForeignKey, Text, JSON, Index, UniqueConstraint, text
)
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)
is_admin = Column(Boolean, default=False)
pocketid_sub = Column(String(256), unique=True, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
# Health profile
max_heart_rate = Column(Integer, nullable=True)
resting_heart_rate = Column(Integer, nullable=True)
birth_year = Column(Integer, nullable=True)
height_cm = Column(Float, nullable=True)
biological_sex = Column(String(8), nullable=True) # 'male' | 'female'
# PocketID config (stored per-user so admin can set via UI)
pocketid_issuer = Column(String(512), nullable=True)
pocketid_client_id = Column(String(256), nullable=True)
pocketid_client_secret = Column(String(256), nullable=True)
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")
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
garmin_connect_config = relationship("GarminConnectConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
class GarminConnectConfig(Base):
"""Per-user Garmin Connect credentials and sync state."""
__tablename__ = "garmin_connect_configs"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True, index=True)
email = Column(String(256), nullable=False)
password_enc = Column(String(512), nullable=False) # Fernet-encrypted
token_store = Column(Text, nullable=True) # garth OAuth2 token JSON
sync_enabled = Column(Boolean, default=True)
sync_activities = Column(Boolean, default=True)
sync_wellness = Column(Boolean, default=True)
sync_lookback_days = Column(Integer, default=30) # -1 = all-time
last_sync_at = Column(DateTime(timezone=True), nullable=True)
last_sync_status = Column(String(512), nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="garmin_connect_config")
class WeightLog(Base):
"""Manual weight entries separate from health_metrics for easy tracking."""
__tablename__ = "weight_logs"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
date = Column(DateTime(timezone=True), nullable=False)
weight_kg = Column(Float, nullable=False)
body_fat_pct = Column(Float, nullable=True)
note = Column(String(256), nullable=True)
__table_args__ = (
Index("ix_weight_user_date", "user_id", "date"),
)
user = relationship("User", back_populates="weight_logs")
class Activity(Base):
__tablename__ = "activities"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(256), nullable=False)
sport_type = Column(String(64), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
end_time = Column(DateTime(timezone=True), nullable=True)
distance_m = Column(Float, nullable=True)
duration_s = Column(Float, nullable=True)
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)
named_route_id = Column(Integer, ForeignKey("named_routes.id", ondelete="SET NULL"), nullable=True)
polyline = Column(Text, nullable=True)
bounding_box = Column(JSON, nullable=True)
source_file = Column(String(512), nullable=True)
source_type = Column(String(32), nullable=True)
garmin_activity_id = Column(String(64), nullable=True, unique=True)
strava_activity_id = Column(String(64), nullable=True, unique=True)
hr_zones = Column(JSON, nullable=True)
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):
__tablename__ = "activity_data_points"
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
timestamp = Column(DateTime(timezone=True), nullable=False, primary_key=True)
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)
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)
bounding_box = Column(JSON, nullable=True)
distance_m = Column(Float, nullable=True)
auto_detected = Column(Boolean, default=False)
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):
__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)
end_distance_m = Column(Float, nullable=False)
description = Column(Text, nullable=True)
auto_generated = Column(Boolean, default=False)
auto_generated_type = Column(String(20), nullable=True) # '1km' | 'turns' | 'hills'
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)
distance_label = Column(String(32), nullable=False)
duration_s = Column(Float, nullable=False)
achieved_at = Column(DateTime(timezone=True), nullable=False)
is_current_record = Column(Boolean, default=True)
__table_args__ = (
# Uniqueness is enforced at runtime by the partial index uq_pr_current_active
# (created in init_db), which only covers is_current_record=true rows.
# The old all-columns UniqueConstraint was dropped because it incorrectly
# constrained is_current_record=false rows too, causing multi-worker races.
Index("uq_pr_current_active", "user_id", "sport_type", "distance_m",
postgresql_where=text("is_current_record = true"), unique=True),
)
class HealthMetric(Base):
__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)
resting_hr = Column(Float, nullable=True)
max_hr_day = Column(Float, nullable=True)
avg_hr_day = Column(Float, nullable=True)
hrv_status = Column(String(32), nullable=True)
hrv_nightly_avg = Column(Float, nullable=True)
hrv_5min_high = Column(Float, nullable=True)
hrv_5min_low = Column(Float, nullable=True)
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)
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)
vo2max = Column(Float, nullable=True)
fitness_age = Column(Integer, nullable=True)
training_load = Column(Float, nullable=True)
recovery_time_h = Column(Float, nullable=True)
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)
intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] — not in API list response
body_battery = Column(JSON, nullable=True) # {charged,drained,start_level,end_level,values:[[ts_ms,level,type,stress]...]}
body_battery_hires = Column(JSON, nullable=True) # [[ts_ms, level], ...] interpolated from bb + HR; higher resolution than raw values
sleep_stages = Column(JSON, nullable=True) # [[ts_ms, level], ...] 0=unmeasurable,1=awake,2=light,3=deep,4=rem
__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")