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) # 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) 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]...]} __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")