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>
262 lines
12 KiB
Python
262 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)
|
|
# Only PocketID users in this group may sign in. Null/blank = allow all.
|
|
pocketid_allowed_group = Column(String(128), 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")
|