ronheichman's picture
Deploy workout tracker
b28b9ac verified
Raw
History Blame Contribute Delete
6.61 kB
from __future__ import annotations
import json
from pathlib import Path
from typing import Iterator
from fastapi import Request
from sqlalchemy import Engine, inspect, text
from sqlmodel import Session, create_engine, select
from tracker.config import TrackerConfig, load_config
from tracker.models import TrackerUser
_engine_by_resolved_path: dict[str, Engine] = {}
USER_SCOPED_TABLES = (
"programs",
"workout_logs",
"body_weight_logs",
"run_logs",
"planned_meals",
"meal_log_entries",
)
def get_engine_for_config(cfg: TrackerConfig) -> Engine:
"""Return a cached engine for this database path (one engine per resolved SQLite file)."""
resolved = str(cfg.tracker_db_full_path.resolve())
existing = _engine_by_resolved_path.get(resolved)
if existing is not None:
return existing
cfg.tracker_db_full_path.parent.mkdir(parents=True, exist_ok=True)
eng = create_engine(f"sqlite:///{resolved}", echo=False)
_engine_by_resolved_path[resolved] = eng
return eng
def _default_user_email(cfg: TrackerConfig) -> str:
if cfg.dashboard_logins:
return cfg.dashboard_logins[0].email
return "local@example.com"
def _ensure_default_user(engine: Engine, cfg: TrackerConfig) -> int:
email = _default_user_email(cfg)
login = next((item for item in cfg.dashboard_logins if item.email == email), None)
with Session(engine) as session:
user = session.exec(select(TrackerUser).where(TrackerUser.email == email)).first()
if user is None:
user = TrackerUser(
email=email,
label=login.label if login is not None else "Local",
role=login.role if login is not None else None,
)
session.add(user)
session.commit()
session.refresh(user)
elif login is not None:
user.label = login.label
user.role = login.role
session.add(user)
session.commit()
session.refresh(user)
if user.user_id is None:
msg = f"Tracker user {email} has no user_id"
raise RuntimeError(msg)
return user.user_id
def _legacy_user_id(engine: Engine) -> int | None:
with Session(engine) as session:
user = session.exec(select(TrackerUser).where(TrackerUser.email == "local@example.com")).first()
return user.user_id if user is not None else None
def _add_user_id_columns(
engine: Engine,
table_names: list[str],
default_user_id: int,
) -> None:
inspector = inspect(engine)
legacy_user_id = _legacy_user_id(engine)
with engine.begin() as connection:
for table_name in USER_SCOPED_TABLES:
if table_name not in table_names:
continue
column_names = {column["name"] for column in inspector.get_columns(table_name)}
if "user_id" not in column_names:
connection.execute(text(f"ALTER TABLE {table_name} ADD COLUMN user_id INTEGER"))
connection.execute(
text(f"UPDATE {table_name} SET user_id = :user_id WHERE user_id IS NULL"),
{"user_id": default_user_id},
)
if legacy_user_id is not None and legacy_user_id != default_user_id:
connection.execute(
text(f"UPDATE {table_name} SET user_id = :user_id WHERE user_id = :legacy_user_id"),
{"user_id": default_user_id, "legacy_user_id": legacy_user_id},
)
def migrate_tracker_schema(engine: Engine, cfg: TrackerConfig) -> None:
"""Apply additive SQLite schema updates for existing tracker.db files."""
inspector = inspect(engine)
table_names = inspector.get_table_names()
default_user_id = _ensure_default_user(engine, cfg)
_add_user_id_columns(engine, table_names, default_user_id)
if "program_exercises" in table_names:
column_names = {column["name"] for column in inspector.get_columns("program_exercises")}
if "starting_weight_kg" not in column_names:
with engine.begin() as connection:
connection.execute(
text("ALTER TABLE program_exercises ADD COLUMN starting_weight_kg REAL"),
)
if "exercises" in table_names:
ex_cols = {column["name"] for column in inspector.get_columns("exercises")}
with engine.begin() as connection:
if "starting_bodyweight_fraction" not in ex_cols:
connection.execute(
text("ALTER TABLE exercises ADD COLUMN starting_bodyweight_fraction REAL"),
)
if "starting_load_kg" not in ex_cols:
connection.execute(
text("ALTER TABLE exercises ADD COLUMN starting_load_kg REAL"),
)
if "starting_load_reference" not in ex_cols:
connection.execute(
text(
"ALTER TABLE exercises ADD COLUMN starting_load_reference TEXT DEFAULT ''",
),
)
if "run_logs" in table_names:
run_cols = {column["name"] for column in inspector.get_columns("run_logs")}
with engine.begin() as connection:
if "program_id" not in run_cols:
connection.execute(text("ALTER TABLE run_logs ADD COLUMN program_id INTEGER"))
if "day_label" not in run_cols:
connection.execute(text("ALTER TABLE run_logs ADD COLUMN day_label TEXT"))
SEED_FILE = Path(__file__).resolve().parent.parent / "seed" / "exercises.json"
def get_session(request: Request) -> Iterator[Session]:
with Session(request.app.state.db_engine) as session:
yield session
def seed_exercises_if_empty(engine: Engine) -> None:
"""Bulk-insert exercises from seed/exercises.json on first run."""
from tracker.models import Exercise
with Session(engine) as session:
count = session.exec(select(Exercise)).first()
if count is not None:
return
if not SEED_FILE.exists():
return
raw = json.loads(SEED_FILE.read_text())
for entry in raw:
session.add(Exercise(
name=entry["name"],
muscle_group=entry.get("muscle_group", ""),
equipment=entry.get("equipment", ""),
wger_id=entry.get("wger_id"),
))
session.commit()
def default_engine() -> Engine:
"""Engine for the process default config (CLI and MCP outside HTTP)."""
return get_engine_for_config(load_config())