Compare commits
15 Commits
91b3d24147
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 059ba8f778 | |||
| 4e6930c207 | |||
| cea1b4e80e | |||
| bae0bc9dee | |||
| df8d5c65fb | |||
| c5a7728818 | |||
| ebecfd0b58 | |||
| 0389aef56e | |||
| 52e48f8ed4 | |||
| 69b3357800 | |||
| 4b117c6fa7 | |||
| 931e452205 | |||
| 5b26f36f5d | |||
| 758034b25a | |||
| 576d3bbb68 |
@@ -5,7 +5,7 @@
|
|||||||
data/
|
data/
|
||||||
docs/
|
docs/
|
||||||
tests/
|
tests/
|
||||||
alembic/
|
alembic/__pycache__
|
||||||
*.md
|
*.md
|
||||||
*.lock
|
*.lock
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
# SneakySwole Environment Configuration
|
# SneakySwole Environment Configuration
|
||||||
# Copy this file to .env and fill in real values.
|
# Copy this file to .env and fill in real values.
|
||||||
|
|
||||||
# Admin credentials (used to create admin user on first run)
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_PASSWORD=changeme
|
|
||||||
|
|
||||||
# App settings
|
# App settings
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
# SneakySwole Production Environment
|
# SneakySwole Production Environment
|
||||||
# Copy to .env on your production server and fill in real values.
|
# Copy to .env on your production server and fill in real values.
|
||||||
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_PASSWORD=
|
|
||||||
|
|
||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
APP_PORT=8000
|
APP_PORT=8000
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY app/ ./app/
|
COPY app/ ./app/
|
||||||
COPY config/ ./config/
|
COPY config/ ./config/
|
||||||
|
COPY alembic/ ./alembic/
|
||||||
|
COPY alembic.ini .
|
||||||
|
|
||||||
# Create data directory for SQLite
|
# Create data directory for SQLite
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|||||||
37
alembic/versions/a1b2c3d4e5f6_remove_auth_columns.py
Normal file
37
alembic/versions/a1b2c3d4e5f6_remove_auth_columns.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""remove auth columns from users
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: 1855836abf6c
|
||||||
|
Create Date: 2026-03-13
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a1b2c3d4e5f6'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '1855836abf6c'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Drop password_hash and is_admin columns, delete admin users."""
|
||||||
|
# Delete admin users before dropping the column
|
||||||
|
op.execute("DELETE FROM users WHERE is_admin = 1")
|
||||||
|
|
||||||
|
# SQLite requires table recreation for column drops
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('password_hash')
|
||||||
|
batch_op.drop_column('is_admin')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Re-add password_hash and is_admin columns."""
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('password_hash', sa.String(), nullable=False, server_default=''))
|
||||||
|
batch_op.add_column(sa.Column('is_admin', sa.Boolean(), nullable=False, server_default='0'))
|
||||||
45
alembic/versions/b2c3d4e5f6g7_simplify_to_starting_weight.py
Normal file
45
alembic/versions/b2c3d4e5f6g7_simplify_to_starting_weight.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""simplify user_exercise_programs to starting_weight
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6g7
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-03-13
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'b2c3d4e5f6g7'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('user_exercise_programs') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('starting_weight', sa.String(), nullable=False, server_default=''))
|
||||||
|
|
||||||
|
op.execute("UPDATE user_exercise_programs SET starting_weight = wk1_weight")
|
||||||
|
|
||||||
|
with op.batch_alter_table('user_exercise_programs') as batch_op:
|
||||||
|
batch_op.drop_column('wk1_reps')
|
||||||
|
batch_op.drop_column('wk4_reps')
|
||||||
|
batch_op.drop_column('wk1_weight')
|
||||||
|
batch_op.drop_column('wk4_weight')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('user_exercise_programs') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('wk1_reps', sa.String(), server_default=''))
|
||||||
|
batch_op.add_column(sa.Column('wk4_reps', sa.String(), server_default=''))
|
||||||
|
batch_op.add_column(sa.Column('wk1_weight', sa.String(), server_default=''))
|
||||||
|
batch_op.add_column(sa.Column('wk4_weight', sa.String(), server_default=''))
|
||||||
|
|
||||||
|
op.execute("UPDATE user_exercise_programs SET wk1_weight = starting_weight")
|
||||||
|
|
||||||
|
with op.batch_alter_table('user_exercise_programs') as batch_op:
|
||||||
|
batch_op.drop_column('starting_weight')
|
||||||
@@ -20,8 +20,6 @@ class Settings(BaseSettings):
|
|||||||
"""Typed application settings loaded from environment variables.
|
"""Typed application settings loaded from environment variables.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
admin_username: Admin login username (from ADMIN_USERNAME env var).
|
|
||||||
admin_password: Admin login password (from ADMIN_PASSWORD env var).
|
|
||||||
app_env: Runtime environment — 'development' or 'production'.
|
app_env: Runtime environment — 'development' or 'production'.
|
||||||
app_host: Host address to bind the server to.
|
app_host: Host address to bind the server to.
|
||||||
app_port: Port number for the server.
|
app_port: Port number for the server.
|
||||||
@@ -29,8 +27,6 @@ class Settings(BaseSettings):
|
|||||||
database_url: SQLite connection string.
|
database_url: SQLite connection string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
admin_username: str
|
|
||||||
admin_password: str
|
|
||||||
app_env: str = "development"
|
app_env: str = "development"
|
||||||
app_host: str = "0.0.0.0"
|
app_host: str = "0.0.0.0"
|
||||||
app_port: int = 8000
|
app_port: int = 8000
|
||||||
@@ -40,6 +36,7 @@ class Settings(BaseSettings):
|
|||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
97
app/main.py
97
app/main.py
@@ -4,15 +4,15 @@ Creates and configures the FastAPI app with routes, templates,
|
|||||||
static files, and structured logging.
|
static files, and structured logging.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
from alembic import command as alembic_command
|
||||||
|
from alembic.config import Config as AlembicConfig
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlmodel import SQLModel, Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
from starlette.requests import Request as StarletteRequest
|
from starlette.requests import Request as StarletteRequest
|
||||||
@@ -23,7 +23,6 @@ from app.models.user import User
|
|||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.database import get_engine, get_db_session
|
from app.database import get_engine, get_db_session
|
||||||
from app.logging_config import setup_logging
|
from app.logging_config import setup_logging
|
||||||
from app.routes.auth import router as auth_router
|
|
||||||
from app.routes.exercises import router as exercises_router
|
from app.routes.exercises import router as exercises_router
|
||||||
from app.routes.history import router as history_router
|
from app.routes.history import router as history_router
|
||||||
from app.routes.health import router as health_router
|
from app.routes.health import router as health_router
|
||||||
@@ -32,11 +31,9 @@ from app.routes.profiles import router as profiles_router
|
|||||||
from app.routes.logging import router as logging_router
|
from app.routes.logging import router as logging_router
|
||||||
from app.routes.workouts import router as workouts_router
|
from app.routes.workouts import router as workouts_router
|
||||||
from app.routes.dashboard import router as dashboard_router
|
from app.routes.dashboard import router as dashboard_router
|
||||||
from app.routes.schedule import router as schedule_router
|
|
||||||
from app.services.seed_service import SeedService
|
from app.services.seed_service import SeedService
|
||||||
from app.services.auth_service import AuthService
|
|
||||||
from app.services.user_service import UserService
|
from app.services.user_service import UserService
|
||||||
from app.utils.auth import SESSION_COOKIE_NAME, NotAuthenticatedError
|
from app.utils.auth import NoProfileSelectedError
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -47,36 +44,71 @@ STATIC_DIR = _BASE_DIR / "static"
|
|||||||
|
|
||||||
|
|
||||||
class NavContextMiddleware(BaseHTTPMiddleware):
|
class NavContextMiddleware(BaseHTTPMiddleware):
|
||||||
"""Injects admin, profiles, and active_profile into request.state for templates."""
|
"""Injects profiles and active_profile into request.state for templates."""
|
||||||
|
|
||||||
async def dispatch(self, request: StarletteRequest, call_next: RequestResponseEndpoint) -> Response:
|
async def dispatch(self, request: StarletteRequest, call_next: RequestResponseEndpoint) -> Response:
|
||||||
request.state.admin = None
|
|
||||||
request.state.profiles = []
|
request.state.profiles = []
|
||||||
request.state.active_profile = None
|
request.state.active_profile = None
|
||||||
|
|
||||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
if hasattr(request.app.state, "engine"):
|
||||||
if token and hasattr(request.app.state, "engine"):
|
|
||||||
try:
|
try:
|
||||||
with Session(request.app.state.engine) as session:
|
with Session(request.app.state.engine) as session:
|
||||||
secret_key = getattr(request.app.state, "secret_key", "")
|
user_service = UserService(session)
|
||||||
auth_service = AuthService(session, secret_key=secret_key)
|
request.state.profiles = user_service.list_users()
|
||||||
user_id = auth_service.validate_session_token(token)
|
|
||||||
if user_id:
|
|
||||||
admin = session.get(User, user_id)
|
|
||||||
if admin and admin.is_admin:
|
|
||||||
request.state.admin = admin
|
|
||||||
user_service = UserService(session)
|
|
||||||
request.state.profiles = user_service.list_users(exclude_admin=True)
|
|
||||||
|
|
||||||
profile_id = request.cookies.get("active_profile_id")
|
profile_id = request.cookies.get("active_profile_id")
|
||||||
if profile_id and profile_id.isdigit():
|
if profile_id and profile_id.isdigit():
|
||||||
request.state.active_profile = user_service.get_user_by_id(int(profile_id))
|
request.state.active_profile = user_service.get_user_by_id(int(profile_id))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_migrations(database_url: str) -> None:
|
||||||
|
"""Run Alembic migrations to bring the DB schema up to date.
|
||||||
|
|
||||||
|
On a fresh DB this creates all tables via the initial migration.
|
||||||
|
On an existing DB created by create_all (no alembic_version table),
|
||||||
|
stamps the last known pre-migration revision, then upgrades.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import create_engine as sa_create_engine, inspect
|
||||||
|
|
||||||
|
project_root = Path(__file__).resolve().parent.parent
|
||||||
|
alembic_cfg = AlembicConfig(str(project_root / "alembic.ini"))
|
||||||
|
alembic_cfg.set_main_option("script_location", str(project_root / "alembic"))
|
||||||
|
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
|
||||||
|
engine = sa_create_engine(database_url)
|
||||||
|
inspector = inspect(engine)
|
||||||
|
existing_tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if existing_tables and "alembic_version" not in existing_tables:
|
||||||
|
# DB was created by create_all — stamp it at the last revision
|
||||||
|
# that matches the current schema so migrations run from there.
|
||||||
|
# Check which schema state we're in by looking at columns.
|
||||||
|
columns = {c["name"] for c in inspector.get_columns("user_exercise_programs")}
|
||||||
|
if "wk1_reps" in columns:
|
||||||
|
# Old schema: stamp at remove-auth migration (before rep ladder)
|
||||||
|
alembic_command.stamp(alembic_cfg, "a1b2c3d4e5f6")
|
||||||
|
logger.info("alembic_stamped", revision="a1b2c3d4e5f6", reason="legacy_db_old_schema")
|
||||||
|
elif "starting_weight" in columns:
|
||||||
|
# New schema already: stamp at head
|
||||||
|
alembic_command.stamp(alembic_cfg, "head")
|
||||||
|
logger.info("alembic_stamped", revision="head", reason="legacy_db_new_schema")
|
||||||
|
else:
|
||||||
|
# Unknown state — stamp at initial and let migrations sort it out
|
||||||
|
alembic_command.stamp(alembic_cfg, "1855836abf6c")
|
||||||
|
logger.info("alembic_stamped", revision="1855836abf6c", reason="legacy_db_unknown")
|
||||||
|
elif not existing_tables:
|
||||||
|
# Fresh DB — create alembic_version table so upgrade starts from scratch
|
||||||
|
logger.info("fresh_database_detected")
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
|
alembic_command.upgrade(alembic_cfg, "head")
|
||||||
|
logger.info("migrations_applied")
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""Create and configure the FastAPI application.
|
"""Create and configure the FastAPI application.
|
||||||
|
|
||||||
@@ -92,10 +124,10 @@ def create_app() -> FastAPI:
|
|||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Redirect unauthenticated requests to login
|
# Redirect to home when no profile is selected
|
||||||
@app.exception_handler(NotAuthenticatedError)
|
@app.exception_handler(NoProfileSelectedError)
|
||||||
async def _not_authenticated_handler(request, exc):
|
async def _no_profile_handler(request, exc):
|
||||||
return RedirectResponse(url="/login", status_code=302)
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
# Mount static files
|
# Mount static files
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
@@ -104,14 +136,10 @@ def create_app() -> FastAPI:
|
|||||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
app.state.templates = templates
|
app.state.templates = templates
|
||||||
|
|
||||||
# Secret key for session signing
|
# Nav context middleware — injects profiles/active_profile into request.state
|
||||||
app.state.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
|
|
||||||
|
|
||||||
# Nav context middleware — injects admin/profiles/active_profile into request.state
|
|
||||||
app.add_middleware(NavContextMiddleware)
|
app.add_middleware(NavContextMiddleware)
|
||||||
|
|
||||||
# Register route modules
|
# Register route modules
|
||||||
app.include_router(auth_router)
|
|
||||||
app.include_router(exercises_router)
|
app.include_router(exercises_router)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(history_router)
|
app.include_router(history_router)
|
||||||
@@ -120,11 +148,10 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(profiles_router)
|
app.include_router(profiles_router)
|
||||||
app.include_router(workouts_router)
|
app.include_router(workouts_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
app.include_router(schedule_router)
|
|
||||||
|
|
||||||
# Database setup
|
# Database setup — run Alembic migrations instead of create_all
|
||||||
engine = get_engine(settings.database_url)
|
engine = get_engine(settings.database_url)
|
||||||
SQLModel.metadata.create_all(engine)
|
_run_migrations(settings.database_url)
|
||||||
app.state.engine = engine
|
app.state.engine = engine
|
||||||
|
|
||||||
# DB session dependency for routes
|
# DB session dependency for routes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""User model for profile management.
|
"""User model for profile management.
|
||||||
|
|
||||||
Stores admin and regular user profiles with physical stats and goals.
|
Stores user profiles with physical stats and goals.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -14,13 +14,11 @@ class User(SQLModel, table=True):
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
id: Primary key, auto-incremented.
|
id: Primary key, auto-incremented.
|
||||||
username: Unique login identifier.
|
username: Unique identifier.
|
||||||
password_hash: bcrypt-hashed password (admin only initially).
|
|
||||||
display_name: Human-readable name shown in the UI.
|
display_name: Human-readable name shown in the UI.
|
||||||
height: User's height as a string (e.g., "6'0\"").
|
height: User's height as a string (e.g., "6'0\"").
|
||||||
weight: User's weight as a string (e.g., "260 lbs").
|
weight: User's weight as a string (e.g., "260 lbs").
|
||||||
goals: Free-text training goals.
|
goals: Free-text training goals.
|
||||||
is_admin: Whether this user has admin privileges.
|
|
||||||
created_at: Timestamp when the record was created.
|
created_at: Timestamp when the record was created.
|
||||||
updated_at: Timestamp of the last update.
|
updated_at: Timestamp of the last update.
|
||||||
"""
|
"""
|
||||||
@@ -29,11 +27,9 @@ class User(SQLModel, table=True):
|
|||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
username: str = Field(index=True, unique=True)
|
username: str = Field(index=True, unique=True)
|
||||||
password_hash: str = Field(default="")
|
|
||||||
display_name: str = Field(default="")
|
display_name: str = Field(default="")
|
||||||
height: Optional[str] = Field(default=None)
|
height: Optional[str] = Field(default=None)
|
||||||
weight: Optional[str] = Field(default=None)
|
weight: Optional[str] = Field(default=None)
|
||||||
goals: Optional[str] = Field(default=None)
|
goals: Optional[str] = Field(default=None)
|
||||||
is_admin: bool = Field(default=False)
|
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""UserExerciseProgram model for per-user exercise programming.
|
"""UserExerciseProgram model for per-user exercise programming.
|
||||||
|
|
||||||
Links a user to an exercise with week 1 and week 4 rep/weight targets.
|
Links a user to an exercise with a starting weight for the rep ladder.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -16,10 +16,7 @@ class UserExerciseProgram(SQLModel, table=True):
|
|||||||
id: Primary key, auto-incremented.
|
id: Primary key, auto-incremented.
|
||||||
user_id: FK to users table.
|
user_id: FK to users table.
|
||||||
exercise_id: FK to exercises table.
|
exercise_id: FK to exercises table.
|
||||||
wk1_reps: Week 1 target reps (string to support "30 sec" style).
|
starting_weight: Starting weight (e.g., "30 lbs", "BW").
|
||||||
wk4_reps: Week 4 target reps.
|
|
||||||
wk1_weight: Week 1 target weight (e.g., "30 lbs", "BW").
|
|
||||||
wk4_weight: Week 4 target weight.
|
|
||||||
created_at: Timestamp when the record was created.
|
created_at: Timestamp when the record was created.
|
||||||
updated_at: Timestamp of the last update.
|
updated_at: Timestamp of the last update.
|
||||||
"""
|
"""
|
||||||
@@ -29,9 +26,6 @@ class UserExerciseProgram(SQLModel, table=True):
|
|||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
user_id: int = Field(foreign_key="users.id", index=True)
|
user_id: int = Field(foreign_key="users.id", index=True)
|
||||||
exercise_id: int = Field(foreign_key="exercises.id", index=True)
|
exercise_id: int = Field(foreign_key="exercises.id", index=True)
|
||||||
wk1_reps: str = Field(default="")
|
starting_weight: str = Field(default="")
|
||||||
wk4_reps: str = Field(default="")
|
|
||||||
wk1_weight: str = Field(default="")
|
|
||||||
wk4_weight: str = Field(default="")
|
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
"""Authentication routes for admin login and logout.
|
|
||||||
|
|
||||||
Handles the login form, credential verification, session cookie
|
|
||||||
management, and logout.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from app.database import get_db_session
|
|
||||||
from app.services.auth_service import AuthService
|
|
||||||
from app.utils.auth import SESSION_COOKIE_NAME
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(tags=["auth"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
|
||||||
async def login_page(request: Request):
|
|
||||||
"""Render the login form.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered login page HTML.
|
|
||||||
"""
|
|
||||||
templates = request.app.state.templates
|
|
||||||
return templates.TemplateResponse("pages/login.html", {
|
|
||||||
"request": request,
|
|
||||||
"error": None,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
|
||||||
async def login_submit(
|
|
||||||
request: Request,
|
|
||||||
session: Session = Depends(get_db_session),
|
|
||||||
):
|
|
||||||
"""Process login form submission.
|
|
||||||
|
|
||||||
Verifies credentials and sets a session cookie on success.
|
|
||||||
Re-renders the login page with an error on failure.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Redirect to home on success, or login page with error.
|
|
||||||
"""
|
|
||||||
form = await request.form()
|
|
||||||
username = form.get("username", "")
|
|
||||||
password = form.get("password", "")
|
|
||||||
|
|
||||||
secret_key = request.app.state.secret_key
|
|
||||||
auth_service = AuthService(session, secret_key=secret_key)
|
|
||||||
user = auth_service.authenticate(username, password)
|
|
||||||
|
|
||||||
if user is None:
|
|
||||||
templates = request.app.state.templates
|
|
||||||
return templates.TemplateResponse("pages/login.html", {
|
|
||||||
"request": request,
|
|
||||||
"error": "Invalid username or password.",
|
|
||||||
}, status_code=200)
|
|
||||||
|
|
||||||
# Create session token and set cookie — httponly and samesite for security
|
|
||||||
token = auth_service.create_session_token(user_id=user.id)
|
|
||||||
response = RedirectResponse(url="/", status_code=303)
|
|
||||||
response.set_cookie(
|
|
||||||
key=SESSION_COOKIE_NAME,
|
|
||||||
value=token,
|
|
||||||
httponly=True,
|
|
||||||
samesite="lax",
|
|
||||||
max_age=86400, # 24 hours
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("login_success", username=username)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logout")
|
|
||||||
async def logout(request: Request):
|
|
||||||
"""Clear the session cookie and redirect to login.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Redirect to login page.
|
|
||||||
"""
|
|
||||||
response = RedirectResponse(url="/login", status_code=303)
|
|
||||||
response.delete_cookie(key=SESSION_COOKIE_NAME)
|
|
||||||
response.delete_cookie(key="active_profile_id")
|
|
||||||
logger.info("logout")
|
|
||||||
return response
|
|
||||||
@@ -1,21 +1,31 @@
|
|||||||
"""Progress dashboard routes.
|
"""Progress dashboard routes.
|
||||||
|
|
||||||
Displays summary statistics, volume charts, and per-exercise progress.
|
Displays summary statistics, volume charts, per-exercise progress,
|
||||||
|
and CSV export of workout history.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.database import get_db_session
|
from app.database import get_db_session
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.analytics_service import AnalyticsService
|
from app.services.analytics_service import AnalyticsService
|
||||||
from app.services.exercise_service import ExerciseService
|
from app.services.exercise_service import ExerciseService
|
||||||
|
from app.services.export_service import ExportService
|
||||||
|
from app.services.import_service import ImportService
|
||||||
from app.services.progression_service import ProgressionService
|
from app.services.progression_service import ProgressionService
|
||||||
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
from app.utils.auth import require_active_profile
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -26,37 +36,110 @@ router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
|||||||
async def dashboard(
|
async def dashboard(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
profile: User = Depends(require_active_profile),
|
||||||
):
|
):
|
||||||
"""Render the progress dashboard for the active profile.
|
"""Render the progress dashboard for the active profile."""
|
||||||
|
analytics = AnalyticsService(session)
|
||||||
Shows: summary stats, volume by day chart, exercise progress links.
|
stats = analytics.get_user_stats(profile.id)
|
||||||
"""
|
volume_data = analytics.get_volume_by_day(profile.id)
|
||||||
active_profile_id = get_active_profile_id(request)
|
personal_records = analytics.get_personal_records(profile.id)
|
||||||
active_profile = (
|
adherence = analytics.get_adherence_rate(profile.id)
|
||||||
session.get(User, active_profile_id)
|
progression_timeline = analytics.get_progression_timeline(profile.id)
|
||||||
if active_profile_id
|
muscle_recency = analytics.get_muscle_group_recency(profile.id)
|
||||||
else None
|
recent_activity = analytics.get_recent_activity(profile.id)
|
||||||
)
|
|
||||||
|
|
||||||
stats = {}
|
|
||||||
volume_data = {}
|
|
||||||
if active_profile_id:
|
|
||||||
analytics = AnalyticsService(session)
|
|
||||||
stats = analytics.get_user_stats(active_profile_id)
|
|
||||||
volume_data = analytics.get_volume_by_day(active_profile_id)
|
|
||||||
|
|
||||||
exercise_service = ExerciseService(session)
|
exercise_service = ExerciseService(session)
|
||||||
exercises = exercise_service.list_exercises()
|
exercises = exercise_service.list_exercises()
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
export_start = (today - timedelta(days=30)).isoformat()
|
||||||
|
export_end = today.isoformat()
|
||||||
|
|
||||||
templates = request.app.state.templates
|
templates = request.app.state.templates
|
||||||
return templates.TemplateResponse("pages/dashboard.html", {
|
return templates.TemplateResponse("pages/dashboard.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
"volume_data_json": json.dumps(volume_data),
|
"volume_data_json": json.dumps(volume_data),
|
||||||
|
"personal_records": personal_records,
|
||||||
|
"adherence": adherence,
|
||||||
|
"progression_timeline_json": json.dumps(progression_timeline),
|
||||||
|
"muscle_recency": muscle_recency,
|
||||||
|
"recent_activity": recent_activity,
|
||||||
"exercises": exercises,
|
"exercises": exercises,
|
||||||
"active_profile": active_profile,
|
"active_profile": profile,
|
||||||
"admin": admin,
|
"export_start_date": export_start,
|
||||||
|
"export_end_date": export_end,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
async def export_csv(
|
||||||
|
request: Request,
|
||||||
|
start_date: Optional[str] = Query(None),
|
||||||
|
end_date: Optional[str] = Query(None),
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
profile: User = Depends(require_active_profile),
|
||||||
|
):
|
||||||
|
"""Export workout history as a CSV file download."""
|
||||||
|
today = date.today()
|
||||||
|
default_start = today - timedelta(days=30)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_start = date.fromisoformat(start_date) if start_date else default_start
|
||||||
|
except ValueError:
|
||||||
|
parsed_start = default_start
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_end = date.fromisoformat(end_date) if end_date else today
|
||||||
|
except ValueError:
|
||||||
|
parsed_end = today
|
||||||
|
|
||||||
|
export_service = ExportService(session)
|
||||||
|
rows = export_service.get_export_rows(profile.id, parsed_start, parsed_end)
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
headers = ["date", "workout_type", "exercise", "set_number", "reps", "weight", "felt_easy"]
|
||||||
|
writer = csv.DictWriter(output, fieldnames=headers)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
safe_name = re.sub(r"[^a-z0-9_]", "", profile.display_name.lower().replace(" ", "_"))
|
||||||
|
filename = f"sneakyswole_{safe_name}_{parsed_start.isoformat()}_to_{parsed_end.isoformat()}.csv"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_class=HTMLResponse)
|
||||||
|
async def import_db(
|
||||||
|
request: Request,
|
||||||
|
db_file: UploadFile = File(...),
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
profile: User = Depends(require_active_profile),
|
||||||
|
):
|
||||||
|
"""Import workout history from an old SneakySwole database file."""
|
||||||
|
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".db")
|
||||||
|
try:
|
||||||
|
with os.fdopen(tmp_fd, "wb") as tmp:
|
||||||
|
content = await db_file.read()
|
||||||
|
logger.info("import_upload_received", filename=db_file.filename, size=len(content))
|
||||||
|
tmp.write(content)
|
||||||
|
|
||||||
|
import_service = ImportService(session)
|
||||||
|
result = import_service.import_from_db(tmp_path)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("import_failed")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("partials/import_results.html", {
|
||||||
|
"request": request,
|
||||||
|
"result": result,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -65,31 +148,21 @@ async def exercise_progress(
|
|||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
profile: User = Depends(require_active_profile),
|
||||||
):
|
):
|
||||||
"""Render per-exercise progress page with charts and suggestions."""
|
"""Render per-exercise progress page with charts and suggestions."""
|
||||||
active_profile_id = get_active_profile_id(request)
|
|
||||||
active_profile = (
|
|
||||||
session.get(User, active_profile_id)
|
|
||||||
if active_profile_id
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
exercise_service = ExerciseService(session)
|
exercise_service = ExerciseService(session)
|
||||||
exercise = exercise_service.get_exercise_by_id(exercise_id)
|
exercise = exercise_service.get_exercise_by_id(exercise_id)
|
||||||
|
|
||||||
progress_data = {}
|
analytics = AnalyticsService(session)
|
||||||
suggestion = {}
|
progress_data = analytics.get_exercise_progress(
|
||||||
if active_profile_id:
|
profile.id, exercise_id,
|
||||||
analytics = AnalyticsService(session)
|
)
|
||||||
progress_data = analytics.get_exercise_progress(
|
|
||||||
active_profile_id, exercise_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
progression = ProgressionService(session)
|
progression = ProgressionService(session)
|
||||||
suggestion = progression.get_suggestion(
|
suggestion = progression.get_suggestion(
|
||||||
active_profile_id, exercise_id,
|
profile.id, exercise_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
templates = request.app.state.templates
|
templates = request.app.state.templates
|
||||||
return templates.TemplateResponse("pages/exercise_progress.html", {
|
return templates.TemplateResponse("pages/exercise_progress.html", {
|
||||||
@@ -97,6 +170,5 @@ async def exercise_progress(
|
|||||||
"exercise": exercise,
|
"exercise": exercise,
|
||||||
"progress_data_json": json.dumps(progress_data),
|
"progress_data_json": json.dumps(progress_data),
|
||||||
"suggestion": suggestion,
|
"suggestion": suggestion,
|
||||||
"active_profile": active_profile,
|
"active_profile": profile,
|
||||||
"admin": admin,
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Exercise browser routes with HTMX search/filter support.
|
"""Exercise browser routes with HTMX search/filter support.
|
||||||
|
|
||||||
All filtering is done via HTMX partial responses — no JSON APIs.
|
All filtering is done via HTMX partial responses -- no JSON APIs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
@@ -11,7 +11,7 @@ from sqlmodel import Session
|
|||||||
from app.database import get_db_session
|
from app.database import get_db_session
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.exercise_service import ExerciseService
|
from app.services.exercise_service import ExerciseService
|
||||||
from app.utils.auth import get_current_admin_user
|
from app.utils.auth import require_active_profile
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -22,18 +22,9 @@ router = APIRouter(prefix="/exercises", tags=["exercises"])
|
|||||||
async def exercise_browser(
|
async def exercise_browser(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
profile: User = Depends(require_active_profile),
|
||||||
):
|
):
|
||||||
"""Render the exercise browser page with all exercises.
|
"""Render the exercise browser page with all exercises."""
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
admin: The authenticated admin user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered exercise browser page.
|
|
||||||
"""
|
|
||||||
exercise_service = ExerciseService(session)
|
exercise_service = ExerciseService(session)
|
||||||
exercises = exercise_service.list_exercises()
|
exercises = exercise_service.list_exercises()
|
||||||
workout_days = exercise_service.list_workout_days()
|
workout_days = exercise_service.list_workout_days()
|
||||||
@@ -47,7 +38,6 @@ async def exercise_browser(
|
|||||||
"exercises": exercises,
|
"exercises": exercises,
|
||||||
"workout_days": workout_days,
|
"workout_days": workout_days,
|
||||||
"muscle_groups": muscle_groups,
|
"muscle_groups": muscle_groups,
|
||||||
"admin": admin,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -55,24 +45,11 @@ async def exercise_browser(
|
|||||||
async def exercise_search(
|
async def exercise_search(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
profile: User = Depends(require_active_profile),
|
||||||
workout_day: str = Query(default="", alias="workout_day"),
|
workout_day: str = Query(default="", alias="workout_day"),
|
||||||
muscle_group: str = Query(default="", alias="muscle_group"),
|
muscle_group: str = Query(default="", alias="muscle_group"),
|
||||||
):
|
):
|
||||||
"""Return filtered exercise list as an HTMX partial.
|
"""Return filtered exercise list as an HTMX partial."""
|
||||||
|
|
||||||
Called via hx-get from the exercise browser filter dropdowns.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
admin: The authenticated admin user.
|
|
||||||
workout_day: Filter by workout day name.
|
|
||||||
muscle_group: Filter by muscle group.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered exercise list partial HTML.
|
|
||||||
"""
|
|
||||||
exercise_service = ExerciseService(session)
|
exercise_service = ExerciseService(session)
|
||||||
exercises = exercise_service.list_exercises(
|
exercises = exercise_service.list_exercises(
|
||||||
workout_day=workout_day or None,
|
workout_day=workout_day or None,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from app.models.user import User
|
|||||||
from app.services.exercise_service import ExerciseService
|
from app.services.exercise_service import ExerciseService
|
||||||
from app.services.log_service import LogService
|
from app.services.log_service import LogService
|
||||||
from app.services.workout_session_service import WorkoutSessionService
|
from app.services.workout_session_service import WorkoutSessionService
|
||||||
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
from app.utils.auth import require_active_profile
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -24,31 +24,11 @@ router = APIRouter(prefix="/history", tags=["history"])
|
|||||||
async def log_history(
|
async def log_history(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
profile: User = Depends(require_active_profile),
|
||||||
):
|
):
|
||||||
"""Display log history for the active profile.
|
"""Display log history for the active profile."""
|
||||||
|
ws_service = WorkoutSessionService(session)
|
||||||
Shows a list of past workout sessions, most recent first.
|
sessions_list = ws_service.list_sessions(user_id=profile.id)
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
admin: The authenticated admin user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered log history page.
|
|
||||||
"""
|
|
||||||
active_profile_id = get_active_profile_id(request)
|
|
||||||
active_profile = (
|
|
||||||
session.get(User, active_profile_id)
|
|
||||||
if active_profile_id
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
sessions_list = []
|
|
||||||
if active_profile_id:
|
|
||||||
ws_service = WorkoutSessionService(session)
|
|
||||||
sessions_list = ws_service.list_sessions(user_id=active_profile_id)
|
|
||||||
|
|
||||||
# Resolve workout day names for display
|
# Resolve workout day names for display
|
||||||
exercise_service = ExerciseService(session)
|
exercise_service = ExerciseService(session)
|
||||||
@@ -59,8 +39,7 @@ async def log_history(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"sessions": sessions_list,
|
"sessions": sessions_list,
|
||||||
"days_by_id": days_by_id,
|
"days_by_id": days_by_id,
|
||||||
"active_profile": active_profile,
|
"active_profile": profile,
|
||||||
"admin": admin,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -69,19 +48,9 @@ async def session_detail(
|
|||||||
session_id: int,
|
session_id: int,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
profile: User = Depends(require_active_profile),
|
||||||
):
|
):
|
||||||
"""Display detailed logs for a specific workout session.
|
"""Display detailed logs for a specific workout session."""
|
||||||
|
|
||||||
Args:
|
|
||||||
session_id: The workout session ID.
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
admin: The authenticated admin user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered session detail page.
|
|
||||||
"""
|
|
||||||
ws_service = WorkoutSessionService(session)
|
ws_service = WorkoutSessionService(session)
|
||||||
ws = ws_service.get_session_by_id(session_id)
|
ws = ws_service.get_session_by_id(session_id)
|
||||||
|
|
||||||
@@ -109,5 +78,4 @@ async def session_detail(
|
|||||||
"logs_by_exercise": logs_by_exercise,
|
"logs_by_exercise": logs_by_exercise,
|
||||||
"exercises_by_id": exercises_by_id,
|
"exercises_by_id": exercises_by_id,
|
||||||
"days_by_id": days_by_id,
|
"days_by_id": days_by_id,
|
||||||
"admin": admin,
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,13 +16,30 @@ from app.models.user import User
|
|||||||
from app.services.log_service import LogService
|
from app.services.log_service import LogService
|
||||||
from app.services.progression_service import ProgressionService
|
from app.services.progression_service import ProgressionService
|
||||||
from app.services.workout_session_service import WorkoutSessionService
|
from app.services.workout_session_service import WorkoutSessionService
|
||||||
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
from app.utils.auth import require_active_profile, get_active_profile_id
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/log", tags=["logging"])
|
router = APIRouter(prefix="/log", tags=["logging"])
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_weight(raw: str) -> str:
|
||||||
|
"""Convert numeric weight input to display format.
|
||||||
|
|
||||||
|
'0' or '' -> 'BW', bare number -> '{n} lbs', already formatted -> pass through.
|
||||||
|
"""
|
||||||
|
raw = raw.strip()
|
||||||
|
if not raw or raw == "0":
|
||||||
|
return "BW"
|
||||||
|
try:
|
||||||
|
num = float(raw)
|
||||||
|
if num == int(num):
|
||||||
|
return f"{int(num)} lbs"
|
||||||
|
return f"{num} lbs"
|
||||||
|
except ValueError:
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
def _get_prefill_values(
|
def _get_prefill_values(
|
||||||
logs: list,
|
logs: list,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -51,41 +68,25 @@ def _get_prefill_values(
|
|||||||
async def log_set(
|
async def log_set(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
profile: User = Depends(require_active_profile),
|
||||||
):
|
):
|
||||||
"""Log a single set for an exercise.
|
"""Log a single set for an exercise.
|
||||||
|
|
||||||
Creates the workout session if it doesn't exist yet (auto-create).
|
Creates the workout session if it doesn't exist yet (auto-create).
|
||||||
Returns the updated log entries partial for this exercise.
|
Returns the updated log entries partial for this exercise.
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
admin: The authenticated admin user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered log entries partial for this exercise.
|
|
||||||
"""
|
"""
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
exercise_id = int(form.get("exercise_id", 0))
|
exercise_id = int(form.get("exercise_id", 0))
|
||||||
workout_day_id = int(form.get("workout_day_id", 0))
|
workout_day_id = int(form.get("workout_day_id", 0))
|
||||||
set_number = int(form.get("set_number", 1))
|
set_number = int(form.get("set_number", 1))
|
||||||
reps = int(form.get("reps", 0))
|
reps = int(form.get("reps", 0))
|
||||||
weight = form.get("weight", "")
|
weight = _normalize_weight(form.get("weight", ""))
|
||||||
felt_easy = form.get("felt_easy") == "on"
|
felt_easy = form.get("felt_easy") == "on"
|
||||||
|
|
||||||
active_profile_id = get_active_profile_id(request)
|
|
||||||
if not active_profile_id:
|
|
||||||
templates = request.app.state.templates
|
|
||||||
return templates.TemplateResponse("partials/flash_message.html", {
|
|
||||||
"request": request,
|
|
||||||
"flash_error": "No profile selected. Switch profiles first.",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Get or create today's session
|
# Get or create today's session
|
||||||
ws_service = WorkoutSessionService(session)
|
ws_service = WorkoutSessionService(session)
|
||||||
ws = ws_service.get_or_create_session(
|
ws = ws_service.get_or_create_session(
|
||||||
user_id=active_profile_id,
|
user_id=profile.id,
|
||||||
workout_day_id=workout_day_id,
|
workout_day_id=workout_day_id,
|
||||||
session_date=date.today(),
|
session_date=date.today(),
|
||||||
)
|
)
|
||||||
@@ -105,7 +106,7 @@ async def log_set(
|
|||||||
logs = log_service.list_logs_for_exercise(ws.id, exercise_id)
|
logs = log_service.list_logs_for_exercise(ws.id, exercise_id)
|
||||||
next_set = len(logs) + 1
|
next_set = len(logs) + 1
|
||||||
suggested_reps, suggested_weight = _get_prefill_values(
|
suggested_reps, suggested_weight = _get_prefill_values(
|
||||||
logs, session, active_profile_id, exercise_id,
|
logs, session, profile.id, exercise_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
templates = request.app.state.templates
|
templates = request.app.state.templates
|
||||||
@@ -126,26 +127,16 @@ async def edit_log(
|
|||||||
log_id: int,
|
log_id: int,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
profile: User = Depends(require_active_profile),
|
||||||
):
|
):
|
||||||
"""Edit an existing log entry.
|
"""Edit an existing log entry."""
|
||||||
|
|
||||||
Args:
|
|
||||||
log_id: The log entry ID.
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
admin: The authenticated admin user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered updated log entry partial.
|
|
||||||
"""
|
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
log_service = LogService(session)
|
log_service = LogService(session)
|
||||||
|
|
||||||
log_service.update_log(
|
log_service.update_log(
|
||||||
log_id,
|
log_id,
|
||||||
reps_completed=int(form.get("reps", 0)),
|
reps_completed=int(form.get("reps", 0)),
|
||||||
weight_used=form.get("weight", ""),
|
weight_used=_normalize_weight(form.get("weight", "")),
|
||||||
felt_easy=form.get("felt_easy") == "on",
|
felt_easy=form.get("felt_easy") == "on",
|
||||||
notes=form.get("notes"),
|
notes=form.get("notes"),
|
||||||
)
|
)
|
||||||
@@ -179,19 +170,9 @@ async def delete_log(
|
|||||||
log_id: int,
|
log_id: int,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
profile: User = Depends(require_active_profile),
|
||||||
):
|
):
|
||||||
"""Delete a log entry.
|
"""Delete a log entry."""
|
||||||
|
|
||||||
Args:
|
|
||||||
log_id: The log entry ID.
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
admin: The authenticated admin user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered updated log entries partial.
|
|
||||||
"""
|
|
||||||
log_service = LogService(session)
|
log_service = LogService(session)
|
||||||
log = log_service.get_log_by_id(log_id)
|
log = log_service.get_log_by_id(log_id)
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,27 @@
|
|||||||
Renders Jinja2 templates for user-facing pages.
|
Renders Jinja2 templates for user-facing pages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.database import get_db_session
|
||||||
|
from app.services.user_service import UserService
|
||||||
|
|
||||||
router = APIRouter(tags=["pages"])
|
router = APIRouter(tags=["pages"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def home_page(request: Request) -> HTMLResponse:
|
async def home_page(
|
||||||
"""Render the home page.
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Render the home page with profile picker or create-profile link."""
|
||||||
|
user_service = UserService(session)
|
||||||
|
profiles = user_service.list_users()
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered home page HTML.
|
|
||||||
"""
|
|
||||||
templates = request.app.state.templates
|
templates = request.app.state.templates
|
||||||
return templates.TemplateResponse(request, "pages/home.html")
|
return templates.TemplateResponse("pages/home.html", {
|
||||||
|
"request": request,
|
||||||
|
"profiles": profiles,
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Profile management routes.
|
"""Profile management routes.
|
||||||
|
|
||||||
Admin can view, create, edit user profiles and switch the active profile.
|
Users can view, create, edit profiles and switch the active profile.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
@@ -11,7 +11,7 @@ from sqlmodel import Session
|
|||||||
from app.database import get_db_session
|
from app.database import get_db_session
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.user_service import UserService
|
from app.services.user_service import UserService
|
||||||
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
from app.utils.auth import require_active_profile, get_active_profile_id
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -22,20 +22,11 @@ router = APIRouter(prefix="/profiles", tags=["profiles"])
|
|||||||
async def list_profiles(
|
async def list_profiles(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
profile: User = Depends(require_active_profile),
|
||||||
):
|
):
|
||||||
"""List all user profiles for the admin.
|
"""List all user profiles."""
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
admin: The authenticated admin user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered profile list page.
|
|
||||||
"""
|
|
||||||
user_service = UserService(session)
|
user_service = UserService(session)
|
||||||
profiles = user_service.list_users(exclude_admin=True)
|
profiles = user_service.list_users()
|
||||||
active_profile_id = get_active_profile_id(request)
|
active_profile_id = get_active_profile_id(request)
|
||||||
|
|
||||||
templates = request.app.state.templates
|
templates = request.app.state.templates
|
||||||
@@ -43,7 +34,6 @@ async def list_profiles(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"profiles": profiles,
|
"profiles": profiles,
|
||||||
"active_profile_id": active_profile_id,
|
"active_profile_id": active_profile_id,
|
||||||
"admin": admin,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -51,63 +41,101 @@ async def list_profiles(
|
|||||||
async def switch_profile(
|
async def switch_profile(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
|
||||||
):
|
):
|
||||||
"""Switch the active user profile.
|
"""Switch the active user profile.
|
||||||
|
|
||||||
Sets a cookie with the selected profile ID.
|
Sets a cookie with the selected profile ID (1 year expiry).
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
admin: The authenticated admin user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Redirect to the referring page with profile cookie set.
|
|
||||||
"""
|
"""
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
profile_id = form.get("profile_id", "")
|
profile_id = form.get("profile_id", "")
|
||||||
|
|
||||||
# Validate profile exists and is not an admin
|
|
||||||
user_service = UserService(session)
|
user_service = UserService(session)
|
||||||
profile = user_service.get_user_by_id(int(profile_id)) if profile_id.isdigit() else None
|
profile = user_service.get_user_by_id(int(profile_id)) if profile_id.isdigit() else None
|
||||||
|
|
||||||
referer = request.headers.get("referer", "/")
|
response = RedirectResponse(url="/workouts", status_code=303)
|
||||||
response = RedirectResponse(url=referer, status_code=303)
|
|
||||||
|
|
||||||
if profile and not profile.is_admin:
|
if profile:
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="active_profile_id",
|
key="active_profile_id",
|
||||||
value=str(profile.id),
|
value=str(profile.id),
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
max_age=86400,
|
max_age=31536000, # 1 year
|
||||||
)
|
)
|
||||||
logger.info("profile_switched", profile_id=profile.id, name=profile.display_name)
|
logger.info("profile_switched", profile_id=profile.id, name=profile.display_name)
|
||||||
else:
|
else:
|
||||||
logger.warning("profile_switch_failed", profile_id=profile_id)
|
logger.warning("profile_switch_failed", profile_id=profile_id)
|
||||||
|
response = RedirectResponse(url="/", status_code=303)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/create", response_class=HTMLResponse)
|
||||||
|
async def create_profile_form(request: Request):
|
||||||
|
"""Render the create-profile form."""
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/profile_create.html", {
|
||||||
|
"request": request,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
async def create_profile(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Create a new profile, set cookie, redirect to workouts."""
|
||||||
|
form = await request.form()
|
||||||
|
display_name = form.get("display_name", "").strip()
|
||||||
|
|
||||||
|
if not display_name:
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/profile_create.html", {
|
||||||
|
"request": request,
|
||||||
|
"error": "Display name is required.",
|
||||||
|
})
|
||||||
|
|
||||||
|
user_service = UserService(session)
|
||||||
|
# Generate username from display name
|
||||||
|
username = display_name.lower().replace(" ", "_")
|
||||||
|
|
||||||
|
# Ensure uniqueness
|
||||||
|
existing = user_service.get_user_by_username(username)
|
||||||
|
if existing:
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/profile_create.html", {
|
||||||
|
"request": request,
|
||||||
|
"error": "A profile with that name already exists.",
|
||||||
|
})
|
||||||
|
|
||||||
|
profile = user_service.create_user(
|
||||||
|
username=username,
|
||||||
|
display_name=display_name,
|
||||||
|
height=form.get("height", "").strip() or None,
|
||||||
|
weight=form.get("weight", "").strip() or None,
|
||||||
|
goals=form.get("goals", "").strip() or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = RedirectResponse(url="/workouts", status_code=303)
|
||||||
|
response.set_cookie(
|
||||||
|
key="active_profile_id",
|
||||||
|
value=str(profile.id),
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=31536000, # 1 year
|
||||||
|
)
|
||||||
|
logger.info("profile_created", profile_id=profile.id, name=profile.display_name)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}/edit", response_class=HTMLResponse)
|
@router.get("/{profile_id}/edit", response_class=HTMLResponse)
|
||||||
async def edit_profile_page(
|
async def edit_profile_page(
|
||||||
profile_id: int,
|
profile_id: int,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
active_profile: User = Depends(require_active_profile),
|
||||||
):
|
):
|
||||||
"""Render the profile edit form.
|
"""Render the profile edit form."""
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: The profile ID to edit.
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
admin: The authenticated admin user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered profile edit page.
|
|
||||||
"""
|
|
||||||
user_service = UserService(session)
|
user_service = UserService(session)
|
||||||
profile = user_service.get_user_by_id(profile_id)
|
profile = user_service.get_user_by_id(profile_id)
|
||||||
|
|
||||||
@@ -115,7 +143,6 @@ async def edit_profile_page(
|
|||||||
return templates.TemplateResponse("pages/profile_edit.html", {
|
return templates.TemplateResponse("pages/profile_edit.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"profile": profile,
|
"profile": profile,
|
||||||
"admin": admin,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -124,19 +151,9 @@ async def update_profile(
|
|||||||
profile_id: int,
|
profile_id: int,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
active_profile: User = Depends(require_active_profile),
|
||||||
):
|
):
|
||||||
"""Process profile edit form submission.
|
"""Process profile edit form submission."""
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: The profile ID to update.
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
admin: The authenticated admin user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Redirect to profiles page.
|
|
||||||
"""
|
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
user_service = UserService(session)
|
user_service = UserService(session)
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
"""4-week schedule calendar routes.
|
|
||||||
|
|
||||||
Displays a calendar view showing which workout day maps to which date.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import date, timedelta
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from app.database import get_db_session
|
|
||||||
from app.models.user import User
|
|
||||||
from app.services.exercise_service import ExerciseService
|
|
||||||
from app.services.workout_session_service import WorkoutSessionService
|
|
||||||
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/schedule", tags=["schedule"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_class=HTMLResponse)
|
|
||||||
async def schedule_view(
|
|
||||||
request: Request,
|
|
||||||
session: Session = Depends(get_db_session),
|
|
||||||
admin: User = Depends(get_current_admin_user),
|
|
||||||
):
|
|
||||||
"""Render the 4-week schedule calendar.
|
|
||||||
|
|
||||||
Shows a 4-week grid where each training day is mapped to a
|
|
||||||
calendar date. Days with completed sessions are highlighted.
|
|
||||||
"""
|
|
||||||
active_profile_id = get_active_profile_id(request)
|
|
||||||
active_profile = (
|
|
||||||
session.get(User, active_profile_id)
|
|
||||||
if active_profile_id
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
exercise_service = ExerciseService(session)
|
|
||||||
workout_days = exercise_service.list_workout_days()
|
|
||||||
|
|
||||||
# Build 4-week calendar starting from Monday of current week
|
|
||||||
today = date.today()
|
|
||||||
monday = today - timedelta(days=today.weekday())
|
|
||||||
|
|
||||||
weeks = []
|
|
||||||
completed_dates = set()
|
|
||||||
|
|
||||||
# Get completed sessions for highlighting
|
|
||||||
if active_profile_id:
|
|
||||||
ws_service = WorkoutSessionService(session)
|
|
||||||
sessions_list = ws_service.list_sessions(
|
|
||||||
user_id=active_profile_id, limit=100,
|
|
||||||
)
|
|
||||||
completed_dates = {ws.date for ws in sessions_list}
|
|
||||||
|
|
||||||
# 4 workout days per week, 4 weeks
|
|
||||||
for week_num in range(4):
|
|
||||||
week_start = monday + timedelta(weeks=week_num)
|
|
||||||
week_data = {
|
|
||||||
"week_number": week_num + 1,
|
|
||||||
"days": [],
|
|
||||||
}
|
|
||||||
for day_offset, workout_day in enumerate(workout_days):
|
|
||||||
training_date = week_start + timedelta(days=day_offset)
|
|
||||||
is_today = training_date == today
|
|
||||||
is_completed = training_date in completed_dates
|
|
||||||
|
|
||||||
week_data["days"].append({
|
|
||||||
"workout_day": workout_day,
|
|
||||||
"date": training_date,
|
|
||||||
"is_today": is_today,
|
|
||||||
"is_completed": is_completed,
|
|
||||||
})
|
|
||||||
weeks.append(week_data)
|
|
||||||
|
|
||||||
templates = request.app.state.templates
|
|
||||||
return templates.TemplateResponse("pages/schedule.html", {
|
|
||||||
"request": request,
|
|
||||||
"weeks": weeks,
|
|
||||||
"active_profile": active_profile,
|
|
||||||
"admin": admin,
|
|
||||||
})
|
|
||||||
@@ -14,11 +14,12 @@ from sqlmodel import Session, select
|
|||||||
from app.database import get_db_session
|
from app.database import get_db_session
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.user_exercise_program import UserExerciseProgram
|
from app.models.user_exercise_program import UserExerciseProgram
|
||||||
|
from app.models.workout_day import WorkoutDay
|
||||||
from app.services.exercise_service import ExerciseService
|
from app.services.exercise_service import ExerciseService
|
||||||
from app.services.log_service import LogService
|
from app.services.log_service import LogService
|
||||||
from app.services.progression_service import ProgressionService
|
from app.services.progression_service import ProgressionService
|
||||||
from app.services.workout_session_service import WorkoutSessionService
|
from app.services.workout_session_service import WorkoutSessionService
|
||||||
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
from app.utils.auth import require_active_profile, get_active_profile_id
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -29,26 +30,42 @@ router = APIRouter(prefix="/workouts", tags=["workouts"])
|
|||||||
async def workout_days_list(
|
async def workout_days_list(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
profile: User = Depends(require_active_profile),
|
||||||
):
|
):
|
||||||
"""List all workout days as clickable cards.
|
"""List all workout days with a recommendation for what to do next."""
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
admin: The authenticated admin user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered workout days list page.
|
|
||||||
"""
|
|
||||||
exercise_service = ExerciseService(session)
|
exercise_service = ExerciseService(session)
|
||||||
days = exercise_service.list_workout_days()
|
days = exercise_service.list_workout_days()
|
||||||
|
|
||||||
|
ws_service = WorkoutSessionService(session)
|
||||||
|
last_session = ws_service.get_last_completed_session(profile.id)
|
||||||
|
|
||||||
|
recommended_day_id = None
|
||||||
|
last_workout_name = None
|
||||||
|
last_workout_date = None
|
||||||
|
|
||||||
|
if last_session:
|
||||||
|
last_day = session.get(WorkoutDay, last_session.workout_day_id)
|
||||||
|
if last_day:
|
||||||
|
last_workout_name = last_day.name
|
||||||
|
last_workout_date = last_session.date
|
||||||
|
next_day_number = (last_day.day_number % 4) + 1
|
||||||
|
for d in days:
|
||||||
|
if d.day_number == next_day_number:
|
||||||
|
recommended_day_id = d.id
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
for d in days:
|
||||||
|
if d.day_number == 1:
|
||||||
|
recommended_day_id = d.id
|
||||||
|
break
|
||||||
|
|
||||||
templates = request.app.state.templates
|
templates = request.app.state.templates
|
||||||
return templates.TemplateResponse("pages/workout_days.html", {
|
return templates.TemplateResponse("pages/workout_days.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"days": days,
|
"days": days,
|
||||||
"admin": admin,
|
"recommended_day_id": recommended_day_id,
|
||||||
|
"last_workout_name": last_workout_name,
|
||||||
|
"last_workout_date": last_workout_date,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -57,19 +74,9 @@ async def workout_day_detail(
|
|||||||
day_name: str,
|
day_name: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
admin: User = Depends(get_current_admin_user),
|
profile: User = Depends(require_active_profile),
|
||||||
):
|
):
|
||||||
"""Display a full workout day -- warmups + exercises with form cues.
|
"""Display a full workout day -- warmups + exercises with form cues."""
|
||||||
|
|
||||||
Args:
|
|
||||||
day_name: The workout day name (e.g., "push", "pull").
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session.
|
|
||||||
admin: The authenticated admin user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered workout day detail page.
|
|
||||||
"""
|
|
||||||
exercise_service = ExerciseService(session)
|
exercise_service = ExerciseService(session)
|
||||||
|
|
||||||
# Normalize day name for DB lookup (e.g., "push" -> "Push", "full-body" -> "Full Body")
|
# Normalize day name for DB lookup (e.g., "push" -> "Push", "full-body" -> "Full Body")
|
||||||
@@ -78,19 +85,15 @@ async def workout_day_detail(
|
|||||||
warmups = exercise_service.list_warmups()
|
warmups = exercise_service.list_warmups()
|
||||||
exercises = exercise_service.list_exercises(workout_day=day_display)
|
exercises = exercise_service.list_exercises(workout_day=day_display)
|
||||||
|
|
||||||
# Get active profile's programming if set
|
# Get active profile's programming
|
||||||
active_profile_id = get_active_profile_id(request)
|
active_profile_id = profile.id
|
||||||
programs = {}
|
programs = {}
|
||||||
active_profile = None
|
|
||||||
existing_logs = {}
|
existing_logs = {}
|
||||||
if active_profile_id:
|
statement = select(UserExerciseProgram).where(
|
||||||
active_profile = session.get(User, active_profile_id)
|
UserExerciseProgram.user_id == active_profile_id
|
||||||
if active_profile:
|
)
|
||||||
statement = select(UserExerciseProgram).where(
|
for prog in session.exec(statement).all():
|
||||||
UserExerciseProgram.user_id == active_profile_id
|
programs[prog.exercise_id] = prog
|
||||||
)
|
|
||||||
for prog in session.exec(statement).all():
|
|
||||||
programs[prog.exercise_id] = prog
|
|
||||||
|
|
||||||
# Look up the workout day ID for logging forms
|
# Look up the workout day ID for logging forms
|
||||||
days = exercise_service.list_workout_days()
|
days = exercise_service.list_workout_days()
|
||||||
@@ -102,15 +105,14 @@ async def workout_day_detail(
|
|||||||
|
|
||||||
# Get progression suggestions for each exercise
|
# Get progression suggestions for each exercise
|
||||||
suggestions = {}
|
suggestions = {}
|
||||||
if active_profile_id:
|
progression = ProgressionService(session)
|
||||||
progression = ProgressionService(session)
|
for exercise in exercises:
|
||||||
for exercise in exercises:
|
suggestions[exercise.id] = progression.get_suggestion(
|
||||||
suggestions[exercise.id] = progression.get_suggestion(
|
active_profile_id, exercise.id,
|
||||||
active_profile_id, exercise.id,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Load existing logs for today's session (if any)
|
# Load existing logs for today's session (if any)
|
||||||
if active_profile_id and workout_day_id:
|
if workout_day_id:
|
||||||
ws_service = WorkoutSessionService(session)
|
ws_service = WorkoutSessionService(session)
|
||||||
ws = ws_service.get_or_create_session(
|
ws = ws_service.get_or_create_session(
|
||||||
user_id=active_profile_id,
|
user_id=active_profile_id,
|
||||||
@@ -129,9 +131,8 @@ async def workout_day_detail(
|
|||||||
"warmups": warmups,
|
"warmups": warmups,
|
||||||
"exercises": exercises,
|
"exercises": exercises,
|
||||||
"programs": programs,
|
"programs": programs,
|
||||||
"active_profile": active_profile,
|
"active_profile": profile,
|
||||||
"existing_logs": existing_logs,
|
"existing_logs": existing_logs,
|
||||||
"suggestions": suggestions,
|
"suggestions": suggestions,
|
||||||
"workout_day_id": workout_day_id,
|
"workout_day_id": workout_day_id,
|
||||||
"admin": admin,
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from datetime import date, timedelta
|
|||||||
import structlog
|
import structlog
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.exercise import Exercise
|
||||||
|
from app.models.progress_log import ProgressLog
|
||||||
from app.models.workout_day import WorkoutDay
|
from app.models.workout_day import WorkoutDay
|
||||||
from app.models.workout_log import WorkoutLog
|
from app.models.workout_log import WorkoutLog
|
||||||
from app.models.workout_session import WorkoutSession
|
from app.models.workout_session import WorkoutSession
|
||||||
@@ -179,3 +181,197 @@ class AnalyticsService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return volume_by_day
|
return volume_by_day
|
||||||
|
|
||||||
|
def get_personal_records(self, user_id: int) -> list[dict]:
|
||||||
|
"""Get per-exercise max weight records for a user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with exercise_name, weight, weight_display, date.
|
||||||
|
Sorted by weight descending. BW-only exercises excluded.
|
||||||
|
"""
|
||||||
|
sessions = self._session.exec(
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(WorkoutSession.user_id == user_id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Map exercise_id -> {max_weight, weight_str, date, exercise_name}
|
||||||
|
records: dict[int, dict] = {}
|
||||||
|
for ws in sessions:
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
|
||||||
|
).all()
|
||||||
|
for log_entry in logs:
|
||||||
|
weight = _weight_to_float(log_entry.weight_used)
|
||||||
|
if weight == 0.0:
|
||||||
|
continue
|
||||||
|
existing = records.get(log_entry.exercise_id)
|
||||||
|
if existing is None or weight > existing["weight"]:
|
||||||
|
records[log_entry.exercise_id] = {
|
||||||
|
"exercise_id": log_entry.exercise_id,
|
||||||
|
"weight": weight,
|
||||||
|
"weight_display": log_entry.weight_used,
|
||||||
|
"date": ws.date,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve exercise names
|
||||||
|
result = []
|
||||||
|
for exercise_id, rec in records.items():
|
||||||
|
exercise = self._session.get(Exercise, exercise_id)
|
||||||
|
if exercise:
|
||||||
|
rec["exercise_name"] = exercise.name
|
||||||
|
result.append(rec)
|
||||||
|
|
||||||
|
result.sort(key=lambda r: r["weight"], reverse=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_adherence_rate(self, user_id: int, weeks: int = 8) -> dict:
|
||||||
|
"""Calculate workout adherence rate over the past N weeks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with rate (0-100), completed, expected, weeks.
|
||||||
|
"""
|
||||||
|
cutoff = date.today() - timedelta(weeks=weeks)
|
||||||
|
sessions = self._session.exec(
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(
|
||||||
|
WorkoutSession.user_id == user_id,
|
||||||
|
WorkoutSession.date >= cutoff,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Only count sessions with logs
|
||||||
|
completed = 0
|
||||||
|
for ws in sessions:
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
|
||||||
|
).all()
|
||||||
|
if logs:
|
||||||
|
completed += 1
|
||||||
|
|
||||||
|
expected = weeks * 4
|
||||||
|
rate = round((completed / expected) * 100) if expected > 0 else 0
|
||||||
|
return {
|
||||||
|
"rate": min(rate, 100),
|
||||||
|
"completed": completed,
|
||||||
|
"expected": expected,
|
||||||
|
"weeks": weeks,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_muscle_group_recency(self, user_id: int) -> list[dict]:
|
||||||
|
"""Get the most recent workout date for each muscle group.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with muscle_group, last_worked, days_ago.
|
||||||
|
Sorted by days_ago descending (most stale first).
|
||||||
|
"""
|
||||||
|
exercises = self._session.exec(select(Exercise)).all()
|
||||||
|
muscle_groups = {e.muscle_group for e in exercises if e.muscle_group}
|
||||||
|
|
||||||
|
# Map exercise_id -> muscle_group for fast lookup
|
||||||
|
ex_muscle = {e.id: e.muscle_group for e in exercises}
|
||||||
|
|
||||||
|
sessions = self._session.exec(
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(WorkoutSession.user_id == user_id)
|
||||||
|
.order_by(WorkoutSession.date.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
recency: dict[str, date] = {}
|
||||||
|
for ws in sessions:
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
|
||||||
|
).all()
|
||||||
|
for log_entry in logs:
|
||||||
|
mg = ex_muscle.get(log_entry.exercise_id, "")
|
||||||
|
if mg and (mg not in recency or ws.date > recency[mg]):
|
||||||
|
recency[mg] = ws.date
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
result = []
|
||||||
|
for mg in sorted(muscle_groups):
|
||||||
|
last_worked = recency.get(mg)
|
||||||
|
days_ago = (today - last_worked).days if last_worked else None
|
||||||
|
result.append({
|
||||||
|
"muscle_group": mg,
|
||||||
|
"last_worked": last_worked,
|
||||||
|
"days_ago": days_ago,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort: never-worked first, then most stale
|
||||||
|
result.sort(
|
||||||
|
key=lambda r: (r["days_ago"] is None, -(r["days_ago"] or 0)),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_recent_activity(self, user_id: int, limit: int = 5) -> list[dict]:
|
||||||
|
"""Get the last N workout sessions with summary data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with date, workout_day_name, total_volume, total_sets.
|
||||||
|
"""
|
||||||
|
days = self._session.exec(select(WorkoutDay)).all()
|
||||||
|
day_map = {d.id: d.name for d in days}
|
||||||
|
|
||||||
|
sessions = self._session.exec(
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(WorkoutSession.user_id == user_id)
|
||||||
|
.order_by(WorkoutSession.date.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for ws in sessions:
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
|
||||||
|
).all()
|
||||||
|
if not logs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_volume = sum(
|
||||||
|
log_entry.reps_completed * _weight_to_float(log_entry.weight_used)
|
||||||
|
for log_entry in logs
|
||||||
|
)
|
||||||
|
result.append({
|
||||||
|
"date": ws.date,
|
||||||
|
"workout_day_name": day_map.get(ws.workout_day_id, "Unknown"),
|
||||||
|
"total_volume": round(total_volume),
|
||||||
|
"total_sets": len(logs),
|
||||||
|
})
|
||||||
|
if len(result) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_progression_timeline(self, user_id: int) -> dict:
|
||||||
|
"""Get progression history for Chart.js multi-line chart.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'exercises' key mapping exercise names to
|
||||||
|
{dates, weights, events} lists.
|
||||||
|
"""
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(ProgressLog)
|
||||||
|
.where(ProgressLog.user_id == user_id)
|
||||||
|
.order_by(ProgressLog.date.asc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
exercises: dict[int, list] = {}
|
||||||
|
for pl in logs:
|
||||||
|
exercises.setdefault(pl.exercise_id, []).append(pl)
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for exercise_id, entries in exercises.items():
|
||||||
|
exercise = self._session.get(Exercise, exercise_id)
|
||||||
|
if not exercise:
|
||||||
|
continue
|
||||||
|
name = exercise.name
|
||||||
|
result[name] = {
|
||||||
|
"dates": [e.date.isoformat() for e in entries],
|
||||||
|
"weights": [
|
||||||
|
_weight_to_float(e.actual_weight or e.suggested_weight or "0")
|
||||||
|
for e in entries
|
||||||
|
],
|
||||||
|
"events": [e.progression_applied or "" for e in entries],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"exercises": result}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
"""Service layer for admin authentication and session management.
|
|
||||||
|
|
||||||
Handles password verification, session token creation/validation.
|
|
||||||
Uses bcrypt for password hashing and itsdangerous for session signing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import bcrypt
|
|
||||||
import structlog
|
|
||||||
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
# Session token max age: 24 hours
|
|
||||||
SESSION_MAX_AGE_SECONDS = 86400
|
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
|
||||||
"""Handles admin authentication and session token management.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: An active SQLModel Session.
|
|
||||||
secret_key: Secret key for signing session tokens.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, session: Session, secret_key: str) -> None:
|
|
||||||
self._session = session
|
|
||||||
self._serializer = URLSafeTimedSerializer(secret_key)
|
|
||||||
|
|
||||||
def authenticate(self, username: str, password: str) -> Optional[User]:
|
|
||||||
"""Verify admin credentials and return the User if valid.
|
|
||||||
|
|
||||||
Only admin users can authenticate. Non-admin users are rejected.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
username: The username to check.
|
|
||||||
password: The plaintext password to verify.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The authenticated User, or None if credentials are invalid.
|
|
||||||
"""
|
|
||||||
statement = select(User).where(User.username == username)
|
|
||||||
user = self._session.exec(statement).first()
|
|
||||||
|
|
||||||
if user is None:
|
|
||||||
logger.warning("auth_failed", reason="user_not_found", username=username)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not user.is_admin:
|
|
||||||
logger.warning("auth_failed", reason="not_admin", username=username)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not user.password_hash:
|
|
||||||
logger.warning("auth_failed", reason="no_password_hash", username=username)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Verify password against bcrypt hash
|
|
||||||
if not bcrypt.checkpw(
|
|
||||||
password.encode("utf-8"),
|
|
||||||
user.password_hash.encode("utf-8"),
|
|
||||||
):
|
|
||||||
logger.warning("auth_failed", reason="wrong_password", username=username)
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.info("auth_success", username=username)
|
|
||||||
return user
|
|
||||||
|
|
||||||
def create_session_token(self, user_id: int) -> str:
|
|
||||||
"""Create a signed session token for the given user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: The authenticated user's ID.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A signed, URL-safe token string.
|
|
||||||
"""
|
|
||||||
return self._serializer.dumps({"user_id": user_id})
|
|
||||||
|
|
||||||
def validate_session_token(self, token: str) -> Optional[int]:
|
|
||||||
"""Validate a session token and extract the user_id.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token: The session token to validate.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The user_id if the token is valid, or None.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = self._serializer.loads(token, max_age=SESSION_MAX_AGE_SECONDS)
|
|
||||||
return data.get("user_id")
|
|
||||||
except BadSignature:
|
|
||||||
logger.warning("session_invalid", reason="bad_signature")
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
logger.warning("session_invalid", reason="unknown_error")
|
|
||||||
return None
|
|
||||||
80
app/services/export_service.py
Normal file
80
app/services/export_service.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Export service for generating CSV-ready workout history data.
|
||||||
|
|
||||||
|
Queries workout logs joined with sessions, days, and exercises
|
||||||
|
to produce flat rows suitable for CSV export.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.exercise import Exercise
|
||||||
|
from app.models.workout_log import WorkoutLog
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ExportService:
|
||||||
|
"""Builds export-ready workout history rows.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active SQLModel Session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def get_export_rows(
|
||||||
|
self, user_id: int, start_date: date, end_date: date,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Get flat workout log rows for CSV export.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user whose data to export.
|
||||||
|
start_date: Inclusive start of date range.
|
||||||
|
end_date: Inclusive end of date range.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with keys: date, workout_type, exercise,
|
||||||
|
set_number, reps, weight, felt_easy.
|
||||||
|
"""
|
||||||
|
sessions = self._session.exec(
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(
|
||||||
|
WorkoutSession.user_id == user_id,
|
||||||
|
WorkoutSession.date >= start_date,
|
||||||
|
WorkoutSession.date <= end_date,
|
||||||
|
)
|
||||||
|
.order_by(WorkoutSession.date.asc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not sessions:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Pre-load exercises for name lookup
|
||||||
|
exercises = self._session.exec(select(Exercise)).all()
|
||||||
|
exercise_map = {e.id: e for e in exercises}
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for ws in sessions:
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(WorkoutLog)
|
||||||
|
.where(WorkoutLog.session_id == ws.id)
|
||||||
|
.order_by(WorkoutLog.exercise_id, WorkoutLog.set_number)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for log_entry in logs:
|
||||||
|
exercise = exercise_map.get(log_entry.exercise_id)
|
||||||
|
rows.append({
|
||||||
|
"date": ws.date.isoformat(),
|
||||||
|
"workout_type": exercise.workout_day if exercise else "Unknown",
|
||||||
|
"exercise": exercise.name if exercise else "Unknown",
|
||||||
|
"set_number": log_entry.set_number,
|
||||||
|
"reps": log_entry.reps_completed,
|
||||||
|
"weight": log_entry.weight_used,
|
||||||
|
"felt_easy": "Yes" if log_entry.felt_easy else "No",
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows
|
||||||
281
app/services/import_service.py
Normal file
281
app/services/import_service.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""Import service for loading workout history from an old database.
|
||||||
|
|
||||||
|
Opens the old SQLite DB read-only, maps IDs by name, and imports
|
||||||
|
workout_sessions, workout_logs, and progress_log into the current DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.exercise import Exercise
|
||||||
|
from app.models.progress_log import ProgressLog
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.workout_day import WorkoutDay
|
||||||
|
from app.models.workout_log import WorkoutLog
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportResult:
|
||||||
|
"""Tracks counts and warnings from an import operation."""
|
||||||
|
|
||||||
|
sessions_imported: int = 0
|
||||||
|
sessions_skipped: int = 0
|
||||||
|
logs_imported: int = 0
|
||||||
|
logs_skipped: int = 0
|
||||||
|
progress_imported: int = 0
|
||||||
|
progress_skipped: int = 0
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ImportService:
|
||||||
|
"""Imports workout history from an old SneakySwole database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active SQLModel Session for the current DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def import_from_db(self, db_path: str) -> ImportResult:
|
||||||
|
"""Import workout data from an old database file.
|
||||||
|
|
||||||
|
Matches users, exercises, and workout days by name between
|
||||||
|
old and new databases. Skips duplicates for idempotency.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the old SQLite database file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ImportResult with counts and any warnings.
|
||||||
|
"""
|
||||||
|
result = ImportResult()
|
||||||
|
|
||||||
|
old_db = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||||
|
old_db.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
user_map = self._build_user_map(old_db, result)
|
||||||
|
exercise_map = self._build_exercise_map(old_db, result)
|
||||||
|
workout_day_map = self._build_workout_day_map(old_db, result)
|
||||||
|
|
||||||
|
session_map = self._import_sessions(
|
||||||
|
old_db, user_map, workout_day_map, result,
|
||||||
|
)
|
||||||
|
self._import_logs(old_db, session_map, exercise_map, result)
|
||||||
|
self._import_progress(old_db, user_map, exercise_map, result)
|
||||||
|
|
||||||
|
self._session.commit()
|
||||||
|
finally:
|
||||||
|
old_db.close()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"import_complete",
|
||||||
|
sessions=result.sessions_imported,
|
||||||
|
logs=result.logs_imported,
|
||||||
|
progress=result.progress_imported,
|
||||||
|
warnings=len(result.warnings),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _build_user_map(
|
||||||
|
self, old_db: sqlite3.Connection, result: ImportResult,
|
||||||
|
) -> dict[int, int]:
|
||||||
|
"""Map old user IDs to new user IDs by matching on username."""
|
||||||
|
new_users = self._session.exec(select(User)).all()
|
||||||
|
new_user_by_name = {u.username: u.id for u in new_users}
|
||||||
|
|
||||||
|
user_map: dict[int, int] = {}
|
||||||
|
for row in old_db.execute("SELECT id, username FROM users"):
|
||||||
|
new_id = new_user_by_name.get(row["username"])
|
||||||
|
if new_id is not None:
|
||||||
|
user_map[row["id"]] = new_id
|
||||||
|
else:
|
||||||
|
result.warnings.append(
|
||||||
|
f"User '{row['username']}' (old id={row['id']}) not found in new DB — skipping their data",
|
||||||
|
)
|
||||||
|
return user_map
|
||||||
|
|
||||||
|
def _build_exercise_map(
|
||||||
|
self, old_db: sqlite3.Connection, result: ImportResult,
|
||||||
|
) -> dict[int, int]:
|
||||||
|
"""Map old exercise IDs to new exercise IDs by matching on name."""
|
||||||
|
new_exercises = self._session.exec(select(Exercise)).all()
|
||||||
|
new_ex_by_name = {e.name: e.id for e in new_exercises}
|
||||||
|
|
||||||
|
exercise_map: dict[int, int] = {}
|
||||||
|
for row in old_db.execute("SELECT id, name FROM exercises"):
|
||||||
|
new_id = new_ex_by_name.get(row["name"])
|
||||||
|
if new_id is not None:
|
||||||
|
exercise_map[row["id"]] = new_id
|
||||||
|
else:
|
||||||
|
result.warnings.append(
|
||||||
|
f"Exercise '{row['name']}' (old id={row['id']}) not found in new DB — skipping its logs",
|
||||||
|
)
|
||||||
|
return exercise_map
|
||||||
|
|
||||||
|
def _build_workout_day_map(
|
||||||
|
self, old_db: sqlite3.Connection, result: ImportResult,
|
||||||
|
) -> dict[int, int]:
|
||||||
|
"""Map old workout_day IDs to new ones by matching on name."""
|
||||||
|
new_days = self._session.exec(select(WorkoutDay)).all()
|
||||||
|
new_day_by_name = {d.name: d.id for d in new_days}
|
||||||
|
|
||||||
|
day_map: dict[int, int] = {}
|
||||||
|
for row in old_db.execute("SELECT id, name FROM workout_days"):
|
||||||
|
new_id = new_day_by_name.get(row["name"])
|
||||||
|
if new_id is not None:
|
||||||
|
day_map[row["id"]] = new_id
|
||||||
|
else:
|
||||||
|
result.warnings.append(
|
||||||
|
f"Workout day '{row['name']}' (old id={row['id']}) not found in new DB",
|
||||||
|
)
|
||||||
|
return day_map
|
||||||
|
|
||||||
|
def _import_sessions(
|
||||||
|
self,
|
||||||
|
old_db: sqlite3.Connection,
|
||||||
|
user_map: dict[int, int],
|
||||||
|
workout_day_map: dict[int, int],
|
||||||
|
result: ImportResult,
|
||||||
|
) -> dict[int, int]:
|
||||||
|
"""Import workout_sessions, returning old_id -> new_id map."""
|
||||||
|
session_map: dict[int, int] = {}
|
||||||
|
|
||||||
|
rows = old_db.execute(
|
||||||
|
"SELECT id, user_id, workout_day_id, date, notes, created_at "
|
||||||
|
"FROM workout_sessions ORDER BY id",
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
new_user_id = user_map.get(row["user_id"])
|
||||||
|
new_day_id = workout_day_map.get(row["workout_day_id"])
|
||||||
|
if new_user_id is None or new_day_id is None:
|
||||||
|
result.sessions_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for duplicate by (user_id, workout_day_id, date)
|
||||||
|
existing = self._session.exec(
|
||||||
|
select(WorkoutSession).where(
|
||||||
|
WorkoutSession.user_id == new_user_id,
|
||||||
|
WorkoutSession.workout_day_id == new_day_id,
|
||||||
|
WorkoutSession.date == row["date"],
|
||||||
|
),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
session_map[row["id"]] = existing.id
|
||||||
|
result.sessions_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_session = WorkoutSession(
|
||||||
|
user_id=new_user_id,
|
||||||
|
workout_day_id=new_day_id,
|
||||||
|
date=date.fromisoformat(row["date"]),
|
||||||
|
notes=row["notes"],
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
)
|
||||||
|
self._session.add(new_session)
|
||||||
|
self._session.flush()
|
||||||
|
session_map[row["id"]] = new_session.id
|
||||||
|
result.sessions_imported += 1
|
||||||
|
|
||||||
|
return session_map
|
||||||
|
|
||||||
|
def _import_logs(
|
||||||
|
self,
|
||||||
|
old_db: sqlite3.Connection,
|
||||||
|
session_map: dict[int, int],
|
||||||
|
exercise_map: dict[int, int],
|
||||||
|
result: ImportResult,
|
||||||
|
) -> None:
|
||||||
|
"""Import workout_logs using mapped session and exercise IDs."""
|
||||||
|
rows = old_db.execute(
|
||||||
|
"SELECT session_id, exercise_id, set_number, reps_completed, "
|
||||||
|
"weight_used, felt_easy, notes, created_at "
|
||||||
|
"FROM workout_logs ORDER BY id",
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
new_session_id = session_map.get(row["session_id"])
|
||||||
|
new_exercise_id = exercise_map.get(row["exercise_id"])
|
||||||
|
if new_session_id is None or new_exercise_id is None:
|
||||||
|
result.logs_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = self._session.exec(
|
||||||
|
select(WorkoutLog).where(
|
||||||
|
WorkoutLog.session_id == new_session_id,
|
||||||
|
WorkoutLog.exercise_id == new_exercise_id,
|
||||||
|
WorkoutLog.set_number == row["set_number"],
|
||||||
|
),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
result.logs_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._session.add(WorkoutLog(
|
||||||
|
session_id=new_session_id,
|
||||||
|
exercise_id=new_exercise_id,
|
||||||
|
set_number=row["set_number"],
|
||||||
|
reps_completed=row["reps_completed"],
|
||||||
|
weight_used=row["weight_used"],
|
||||||
|
felt_easy=bool(row["felt_easy"]),
|
||||||
|
notes=row["notes"],
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
))
|
||||||
|
result.logs_imported += 1
|
||||||
|
|
||||||
|
def _import_progress(
|
||||||
|
self,
|
||||||
|
old_db: sqlite3.Connection,
|
||||||
|
user_map: dict[int, int],
|
||||||
|
exercise_map: dict[int, int],
|
||||||
|
result: ImportResult,
|
||||||
|
) -> None:
|
||||||
|
"""Import progress_log using mapped user and exercise IDs."""
|
||||||
|
rows = old_db.execute(
|
||||||
|
"SELECT user_id, exercise_id, date, suggested_reps, "
|
||||||
|
"suggested_weight, actual_reps, actual_weight, "
|
||||||
|
"progression_applied, created_at "
|
||||||
|
"FROM progress_log ORDER BY id",
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
new_user_id = user_map.get(row["user_id"])
|
||||||
|
new_exercise_id = exercise_map.get(row["exercise_id"])
|
||||||
|
if new_user_id is None or new_exercise_id is None:
|
||||||
|
result.progress_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = self._session.exec(
|
||||||
|
select(ProgressLog).where(
|
||||||
|
ProgressLog.user_id == new_user_id,
|
||||||
|
ProgressLog.exercise_id == new_exercise_id,
|
||||||
|
ProgressLog.date == row["date"],
|
||||||
|
),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
result.progress_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._session.add(ProgressLog(
|
||||||
|
user_id=new_user_id,
|
||||||
|
exercise_id=new_exercise_id,
|
||||||
|
date=date.fromisoformat(row["date"]),
|
||||||
|
suggested_reps=row["suggested_reps"],
|
||||||
|
suggested_weight=row["suggested_weight"],
|
||||||
|
actual_reps=row["actual_reps"],
|
||||||
|
actual_weight=row["actual_weight"],
|
||||||
|
progression_applied=row["progression_applied"],
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
))
|
||||||
|
result.progress_imported += 1
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
"""Auto-progression engine for workout programming.
|
"""Auto-progression engine using a rep ladder model.
|
||||||
|
|
||||||
Analyzes workout log history and applies the progression model:
|
Every exercise follows the same 6 → 8 → 10 → 12 rep ladder at current weight.
|
||||||
- +1-2 reps/week until wk4 rep target
|
At 12 reps with all sets felt easy, weight increases by 5 lbs and reps reset to 6.
|
||||||
- +5 lbs every 2 weeks once at rep target
|
Deload triggers after 4+ consecutive struggling sessions (-20% weight, reset to 6).
|
||||||
- Deload at week 5 (-20% weight, reset to wk1 reps)
|
|
||||||
- Accelerated weight increase when all sets felt easy
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -21,6 +19,12 @@ from app.models.workout_session import WorkoutSession
|
|||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
REP_LADDER = [6, 8, 10, 12]
|
||||||
|
SETS_PER_EXERCISE = 3
|
||||||
|
WEIGHT_INCREMENT = 5
|
||||||
|
DELOAD_FACTOR = 0.8
|
||||||
|
STRUGGLE_THRESHOLD = 4
|
||||||
|
|
||||||
|
|
||||||
def _parse_weight(weight_str: str) -> Optional[float]:
|
def _parse_weight(weight_str: str) -> Optional[float]:
|
||||||
"""Extract numeric weight from a string like '30 lbs' or 'BW'.
|
"""Extract numeric weight from a string like '30 lbs' or 'BW'.
|
||||||
@@ -53,8 +57,27 @@ def _format_weight(weight_lbs: Optional[float]) -> str:
|
|||||||
return f"{weight_lbs:.1f} lbs"
|
return f"{weight_lbs:.1f} lbs"
|
||||||
|
|
||||||
|
|
||||||
|
def _snap_to_ladder(reps: int) -> int:
|
||||||
|
"""Clamp reps into the ladder range [6, 12]."""
|
||||||
|
return max(REP_LADDER[0], min(reps, REP_LADDER[-1]))
|
||||||
|
|
||||||
|
|
||||||
|
def _ladder_position(reps: int) -> int:
|
||||||
|
"""Return the index (0-3) of reps in REP_LADDER, or -1 if outside."""
|
||||||
|
snapped = _snap_to_ladder(reps)
|
||||||
|
try:
|
||||||
|
return REP_LADDER.index(snapped)
|
||||||
|
except ValueError:
|
||||||
|
# reps is in range but not on a ladder step (e.g. 7, 9, 11)
|
||||||
|
# find the highest step at or below current reps
|
||||||
|
for i in range(len(REP_LADDER) - 1, -1, -1):
|
||||||
|
if REP_LADDER[i] <= snapped:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
class ProgressionService:
|
class ProgressionService:
|
||||||
"""Implements the auto-progression engine.
|
"""Implements the rep ladder auto-progression engine.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: An active SQLModel Session.
|
session: An active SQLModel Session.
|
||||||
@@ -120,14 +143,11 @@ class ProgressionService:
|
|||||||
def get_suggestion(
|
def get_suggestion(
|
||||||
self, user_id: int, exercise_id: int,
|
self, user_id: int, exercise_id: int,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Generate a progression suggestion for the next workout.
|
"""Generate a progression suggestion using the rep ladder model.
|
||||||
|
|
||||||
Analyzes recent log history against the user's program targets
|
|
||||||
and applies progression rules.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with keys: suggested_reps, suggested_weight,
|
Dict with keys: suggested_reps, suggested_weight, suggested_sets,
|
||||||
progression_type, message.
|
ladder_position, progression_type, message.
|
||||||
"""
|
"""
|
||||||
program = self._get_program(user_id, exercise_id)
|
program = self._get_program(user_id, exercise_id)
|
||||||
|
|
||||||
@@ -135,107 +155,118 @@ class ProgressionService:
|
|||||||
return {
|
return {
|
||||||
"suggested_reps": 0,
|
"suggested_reps": 0,
|
||||||
"suggested_weight": "",
|
"suggested_weight": "",
|
||||||
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": -1,
|
||||||
"progression_type": "no_program",
|
"progression_type": "no_program",
|
||||||
"message": "No program found for this exercise.",
|
"message": "No program found for this exercise.",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
starting_weight = program.starting_weight
|
||||||
wk1_reps = int(program.wk1_reps)
|
|
||||||
wk4_reps = int(program.wk4_reps)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
wk1_reps = 0
|
|
||||||
wk4_reps = 0
|
|
||||||
|
|
||||||
wk1_weight = program.wk1_weight
|
|
||||||
|
|
||||||
recent = self._get_recent_sessions(user_id, exercise_id, limit=5)
|
recent = self._get_recent_sessions(user_id, exercise_id, limit=5)
|
||||||
|
|
||||||
|
# No history — baseline suggestion
|
||||||
if not recent:
|
if not recent:
|
||||||
return {
|
return {
|
||||||
"suggested_reps": wk1_reps,
|
"suggested_reps": REP_LADDER[0],
|
||||||
"suggested_weight": wk1_weight,
|
"suggested_weight": starting_weight,
|
||||||
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": 0,
|
||||||
"progression_type": "baseline",
|
"progression_type": "baseline",
|
||||||
"message": f"Start with {wk1_reps} reps @ {wk1_weight}.",
|
"message": f"Start with {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ {starting_weight}.",
|
||||||
}
|
}
|
||||||
|
|
||||||
latest = recent[0]
|
latest = recent[0]
|
||||||
current_reps = int(round(latest["avg_reps"]))
|
current_reps = _snap_to_ladder(int(round(latest["avg_reps"])))
|
||||||
current_weight = latest["weight"]
|
current_weight = latest["weight"]
|
||||||
current_weight_num = _parse_weight(current_weight)
|
current_weight_num = _parse_weight(current_weight)
|
||||||
consecutive_sessions = len(recent)
|
all_felt_easy = latest["all_felt_easy"]
|
||||||
|
|
||||||
# Rule: Deload at week 5 (4 consecutive sessions completed)
|
# Count consecutive struggling sessions (not felt easy)
|
||||||
if consecutive_sessions >= 4:
|
struggle_count = 0
|
||||||
|
for s in recent:
|
||||||
|
if not s["all_felt_easy"]:
|
||||||
|
struggle_count += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Deload: 4+ consecutive struggling sessions
|
||||||
|
if struggle_count >= STRUGGLE_THRESHOLD:
|
||||||
if current_weight_num is not None:
|
if current_weight_num is not None:
|
||||||
deload_weight = current_weight_num * 0.8
|
deload_weight = current_weight_num * DELOAD_FACTOR
|
||||||
return {
|
return {
|
||||||
"suggested_reps": wk1_reps,
|
"suggested_reps": REP_LADDER[0],
|
||||||
"suggested_weight": _format_weight(deload_weight),
|
"suggested_weight": _format_weight(deload_weight),
|
||||||
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": 0,
|
||||||
"progression_type": "deload",
|
"progression_type": "deload",
|
||||||
"message": (
|
"message": (
|
||||||
f"Deload week: {wk1_reps} reps @ "
|
f"Deload: {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ "
|
||||||
f"{_format_weight(deload_weight)} (-20%)."
|
f"{_format_weight(deload_weight)} (-20%)."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
# Bodyweight — can't reduce weight, just reset reps
|
||||||
return {
|
return {
|
||||||
"suggested_reps": wk1_reps,
|
"suggested_reps": REP_LADDER[0],
|
||||||
"suggested_weight": current_weight,
|
"suggested_weight": current_weight,
|
||||||
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": 0,
|
||||||
"progression_type": "deload",
|
"progression_type": "deload",
|
||||||
"message": f"Deload week: reset to {wk1_reps} reps.",
|
"message": f"Deload: reset to {SETS_PER_EXERCISE}x{REP_LADDER[0]}.",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Rule: Weight increase if at rep target and felt easy
|
# At top of ladder (12 reps) and felt easy
|
||||||
if current_reps >= wk4_reps and latest["all_felt_easy"]:
|
if current_reps >= REP_LADDER[-1] and all_felt_easy:
|
||||||
if current_weight_num is not None:
|
if current_weight_num is not None:
|
||||||
new_weight = current_weight_num + 5
|
new_weight = current_weight_num + WEIGHT_INCREMENT
|
||||||
return {
|
return {
|
||||||
"suggested_reps": wk1_reps,
|
"suggested_reps": REP_LADDER[0],
|
||||||
"suggested_weight": _format_weight(new_weight),
|
"suggested_weight": _format_weight(new_weight),
|
||||||
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": 0,
|
||||||
"progression_type": "weight_increase",
|
"progression_type": "weight_increase",
|
||||||
"message": (
|
"message": (
|
||||||
f"Weight up: {wk1_reps} reps @ "
|
f"Weight up: {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ "
|
||||||
f"{_format_weight(new_weight)} (+5 lbs)."
|
f"{_format_weight(new_weight)} (+{WEIGHT_INCREMENT} lbs)."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
# Bodyweight — hold at top
|
||||||
# Rule: Weight increase after 2 weeks at rep target
|
|
||||||
if (
|
|
||||||
current_reps >= wk4_reps
|
|
||||||
and len(recent) >= 2
|
|
||||||
and int(round(recent[1]["avg_reps"])) >= wk4_reps
|
|
||||||
):
|
|
||||||
if current_weight_num is not None:
|
|
||||||
new_weight = current_weight_num + 5
|
|
||||||
return {
|
|
||||||
"suggested_reps": wk1_reps,
|
|
||||||
"suggested_weight": _format_weight(new_weight),
|
|
||||||
"progression_type": "weight_increase",
|
|
||||||
"message": (
|
|
||||||
f"2 weeks at target: {wk1_reps} reps @ "
|
|
||||||
f"{_format_weight(new_weight)} (+5 lbs)."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Rule: Rep increase (+1-2 reps)
|
|
||||||
if current_reps < wk4_reps:
|
|
||||||
increment = 2 if latest["all_felt_easy"] else 1
|
|
||||||
new_reps = min(current_reps + increment, wk4_reps)
|
|
||||||
return {
|
return {
|
||||||
"suggested_reps": new_reps,
|
"suggested_reps": REP_LADDER[-1],
|
||||||
"suggested_weight": current_weight,
|
"suggested_weight": current_weight,
|
||||||
"progression_type": "reps_increase",
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": len(REP_LADDER) - 1,
|
||||||
|
"progression_type": "hold_at_top",
|
||||||
"message": (
|
"message": (
|
||||||
f"Reps up: {new_reps} reps @ {current_weight} "
|
f"Hold: {SETS_PER_EXERCISE}x{REP_LADDER[-1]} @ {current_weight} "
|
||||||
f"(+{increment})."
|
f"(bodyweight max)."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Hold: at target, waiting for biweekly weight increase
|
# Below top and felt easy — climb to next ladder step
|
||||||
|
if all_felt_easy:
|
||||||
|
pos = _ladder_position(current_reps)
|
||||||
|
next_pos = min(pos + 1, len(REP_LADDER) - 1)
|
||||||
|
next_reps = REP_LADDER[next_pos]
|
||||||
|
return {
|
||||||
|
"suggested_reps": next_reps,
|
||||||
|
"suggested_weight": current_weight,
|
||||||
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": next_pos,
|
||||||
|
"progression_type": "climb",
|
||||||
|
"message": (
|
||||||
|
f"Climb: {SETS_PER_EXERCISE}x{next_reps} @ {current_weight}."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Not all felt easy — hold at current
|
||||||
|
pos = _ladder_position(current_reps)
|
||||||
return {
|
return {
|
||||||
"suggested_reps": current_reps,
|
"suggested_reps": current_reps,
|
||||||
"suggested_weight": current_weight,
|
"suggested_weight": current_weight,
|
||||||
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": pos,
|
||||||
"progression_type": "hold",
|
"progression_type": "hold",
|
||||||
"message": f"Hold at {current_reps} reps @ {current_weight}.",
|
"message": f"Hold: {SETS_PER_EXERCISE}x{current_reps} @ {current_weight}.",
|
||||||
}
|
}
|
||||||
|
|
||||||
def record_progression(
|
def record_progression(
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ Reads config/exercises.yaml and config/user_programs.yaml to populate
|
|||||||
the exercise library, warmups, workout days, and user programs.
|
the exercise library, warmups, workout days, and user programs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import bcrypt
|
|
||||||
import structlog
|
import structlog
|
||||||
import yaml
|
import yaml
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@@ -43,14 +41,7 @@ class SeedService:
|
|||||||
self._config_dir = config_dir or Path(__file__).resolve().parent.parent.parent / "config"
|
self._config_dir = config_dir or Path(__file__).resolve().parent.parent.parent / "config"
|
||||||
|
|
||||||
def _load_yaml(self, filename: str) -> dict:
|
def _load_yaml(self, filename: str) -> dict:
|
||||||
"""Load and parse a YAML file from the config directory.
|
"""Load and parse a YAML file from the config directory."""
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Name of the YAML file to load.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Parsed YAML content as a dictionary.
|
|
||||||
"""
|
|
||||||
filepath = self._config_dir / filename
|
filepath = self._config_dir / filename
|
||||||
with open(filepath, "r") as f:
|
with open(filepath, "r") as f:
|
||||||
return yaml.safe_load(f)
|
return yaml.safe_load(f)
|
||||||
@@ -111,48 +102,8 @@ class SeedService:
|
|||||||
self._session.commit()
|
self._session.commit()
|
||||||
logger.info("seed_complete", table="warmups", count=len(warmups))
|
logger.info("seed_complete", table="warmups", count=len(warmups))
|
||||||
|
|
||||||
def seed_admin(self) -> None:
|
|
||||||
"""Create admin user from environment variables if not exists.
|
|
||||||
|
|
||||||
Reads ADMIN_USERNAME and ADMIN_PASSWORD from env,
|
|
||||||
hashes the password with bcrypt, and creates the admin user.
|
|
||||||
"""
|
|
||||||
admin_username = os.environ.get("ADMIN_USERNAME", "admin")
|
|
||||||
admin_password = os.environ.get("ADMIN_PASSWORD", "")
|
|
||||||
|
|
||||||
existing = self._session.exec(
|
|
||||||
select(User).where(User.username == admin_username)
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
logger.info("seed_skipped", table="users", reason="admin already exists")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not admin_password:
|
|
||||||
logger.warning("seed_skipped", table="users", reason="ADMIN_PASSWORD not set")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Hash password with bcrypt
|
|
||||||
password_hash = bcrypt.hashpw(
|
|
||||||
admin_password.encode("utf-8"),
|
|
||||||
bcrypt.gensalt(),
|
|
||||||
).decode("utf-8")
|
|
||||||
|
|
||||||
admin = User(
|
|
||||||
username=admin_username,
|
|
||||||
password_hash=password_hash,
|
|
||||||
display_name="Admin",
|
|
||||||
is_admin=True,
|
|
||||||
)
|
|
||||||
self._session.add(admin)
|
|
||||||
self._session.commit()
|
|
||||||
logger.info("seed_complete", table="users", user="admin")
|
|
||||||
|
|
||||||
def seed_user_programs(self) -> None:
|
def seed_user_programs(self) -> None:
|
||||||
"""Seed user profiles and their exercise programs from user_programs.yaml.
|
"""Seed user profiles and their exercise programs from user_programs.yaml."""
|
||||||
|
|
||||||
Creates non-admin user profiles and links them to exercises
|
|
||||||
with week 1/4 rep and weight targets.
|
|
||||||
"""
|
|
||||||
data = self._load_yaml("user_programs.yaml")
|
data = self._load_yaml("user_programs.yaml")
|
||||||
programs = data.get("programs", [])
|
programs = data.get("programs", [])
|
||||||
|
|
||||||
@@ -172,12 +123,10 @@ class SeedService:
|
|||||||
else:
|
else:
|
||||||
user = User(
|
user = User(
|
||||||
username=username,
|
username=username,
|
||||||
password_hash="", # Non-admin users don't log in initially
|
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
height=profile.get("height", ""),
|
height=profile.get("height", ""),
|
||||||
weight=profile.get("weight", ""),
|
weight=profile.get("weight", ""),
|
||||||
goals=profile.get("goals", ""),
|
goals=profile.get("goals", ""),
|
||||||
is_admin=False,
|
|
||||||
)
|
)
|
||||||
self._session.add(user)
|
self._session.add(user)
|
||||||
self._session.commit()
|
self._session.commit()
|
||||||
@@ -208,10 +157,7 @@ class SeedService:
|
|||||||
uep = UserExerciseProgram(
|
uep = UserExerciseProgram(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
exercise_id=exercise.id,
|
exercise_id=exercise.id,
|
||||||
wk1_reps=str(ex_data.get("wk1_reps", "")),
|
starting_weight=str(ex_data.get("starting_weight", "")),
|
||||||
wk4_reps=str(ex_data.get("wk4_reps", "")),
|
|
||||||
wk1_weight=str(ex_data.get("wk1_weight", "")),
|
|
||||||
wk4_weight=str(ex_data.get("wk4_weight", "")),
|
|
||||||
)
|
)
|
||||||
self._session.add(uep)
|
self._session.add(uep)
|
||||||
|
|
||||||
@@ -219,15 +165,10 @@ class SeedService:
|
|||||||
logger.info("seed_complete", table="user_exercise_programs", user=username)
|
logger.info("seed_complete", table="user_exercise_programs", user=username)
|
||||||
|
|
||||||
def seed_all(self) -> None:
|
def seed_all(self) -> None:
|
||||||
"""Run all seed operations in the correct order.
|
"""Run all seed operations in the correct order."""
|
||||||
|
|
||||||
Order matters: workout_days and exercises must exist before
|
|
||||||
user_programs can reference them.
|
|
||||||
"""
|
|
||||||
logger.info("seed_all_started")
|
logger.info("seed_all_started")
|
||||||
self.seed_workout_days()
|
self.seed_workout_days()
|
||||||
self.seed_exercises()
|
self.seed_exercises()
|
||||||
self.seed_warmups()
|
self.seed_warmups()
|
||||||
self.seed_admin()
|
|
||||||
self.seed_user_programs()
|
self.seed_user_programs()
|
||||||
logger.info("seed_all_complete")
|
logger.info("seed_all_complete")
|
||||||
|
|||||||
@@ -27,77 +27,48 @@ class UserService:
|
|||||||
def create_user(
|
def create_user(
|
||||||
self,
|
self,
|
||||||
username: str,
|
username: str,
|
||||||
password_hash: str,
|
|
||||||
display_name: str,
|
display_name: str,
|
||||||
height: Optional[str] = None,
|
height: Optional[str] = None,
|
||||||
weight: Optional[str] = None,
|
weight: Optional[str] = None,
|
||||||
goals: Optional[str] = None,
|
goals: Optional[str] = None,
|
||||||
is_admin: bool = False,
|
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Create a new user profile.
|
"""Create a new user profile.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username: Unique login identifier.
|
username: Unique identifier.
|
||||||
password_hash: Pre-hashed password string.
|
|
||||||
display_name: Human-readable name.
|
display_name: Human-readable name.
|
||||||
height: User height as string.
|
height: User height as string.
|
||||||
weight: User weight as string.
|
weight: User weight as string.
|
||||||
goals: Free-text goals.
|
goals: Free-text goals.
|
||||||
is_admin: Whether user has admin privileges.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The newly created User record.
|
The newly created User record.
|
||||||
"""
|
"""
|
||||||
user = User(
|
user = User(
|
||||||
username=username,
|
username=username,
|
||||||
password_hash=password_hash,
|
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
height=height,
|
height=height,
|
||||||
weight=weight,
|
weight=weight,
|
||||||
goals=goals,
|
goals=goals,
|
||||||
is_admin=is_admin,
|
|
||||||
)
|
)
|
||||||
self._session.add(user)
|
self._session.add(user)
|
||||||
self._session.commit()
|
self._session.commit()
|
||||||
self._session.refresh(user)
|
self._session.refresh(user)
|
||||||
logger.info("user_created", username=username, is_admin=is_admin)
|
logger.info("user_created", username=username)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def get_user_by_id(self, user_id: int) -> Optional[User]:
|
def get_user_by_id(self, user_id: int) -> Optional[User]:
|
||||||
"""Retrieve a user by primary key.
|
"""Retrieve a user by primary key."""
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: The user's ID.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The User record, or None if not found.
|
|
||||||
"""
|
|
||||||
return self._session.get(User, user_id)
|
return self._session.get(User, user_id)
|
||||||
|
|
||||||
def get_user_by_username(self, username: str) -> Optional[User]:
|
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||||
"""Retrieve a user by username.
|
"""Retrieve a user by username."""
|
||||||
|
|
||||||
Args:
|
|
||||||
username: The username to look up.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The User record, or None if not found.
|
|
||||||
"""
|
|
||||||
statement = select(User).where(User.username == username)
|
statement = select(User).where(User.username == username)
|
||||||
return self._session.exec(statement).first()
|
return self._session.exec(statement).first()
|
||||||
|
|
||||||
def list_users(self, exclude_admin: bool = False) -> list[User]:
|
def list_users(self) -> list[User]:
|
||||||
"""List all user profiles.
|
"""List all user profiles."""
|
||||||
|
|
||||||
Args:
|
|
||||||
exclude_admin: If True, omit admin users from the result.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of User records.
|
|
||||||
"""
|
|
||||||
statement = select(User)
|
statement = select(User)
|
||||||
if exclude_admin:
|
|
||||||
statement = statement.where(User.is_admin == False) # noqa: E712
|
|
||||||
return list(self._session.exec(statement).all())
|
return list(self._session.exec(statement).all())
|
||||||
|
|
||||||
def update_user(self, user_id: int, **kwargs) -> User:
|
def update_user(self, user_id: int, **kwargs) -> User:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from typing import Optional
|
|||||||
import structlog
|
import structlog
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.workout_log import WorkoutLog
|
||||||
from app.models.workout_session import WorkoutSession
|
from app.models.workout_session import WorkoutSession
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
@@ -71,6 +72,17 @@ class WorkoutSessionService:
|
|||||||
)
|
)
|
||||||
return ws
|
return ws
|
||||||
|
|
||||||
|
def get_last_completed_session(self, user_id: int) -> Optional[WorkoutSession]:
|
||||||
|
"""Get the most recent session that has at least one logged set."""
|
||||||
|
statement = (
|
||||||
|
select(WorkoutSession)
|
||||||
|
.join(WorkoutLog, WorkoutLog.session_id == WorkoutSession.id)
|
||||||
|
.where(WorkoutSession.user_id == user_id)
|
||||||
|
.order_by(WorkoutSession.date.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return self._session.exec(statement).first()
|
||||||
|
|
||||||
def list_sessions(
|
def list_sessions(
|
||||||
self,
|
self,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -34,3 +34,36 @@ main.container {
|
|||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Muscle group heatmap */
|
||||||
|
.muscle-heatmap {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muscle-heatmap-cell {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recency-fresh {
|
||||||
|
background: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recency-ok {
|
||||||
|
background: rgba(234, 179, 8, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recency-stale {
|
||||||
|
background: rgba(249, 115, 22, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recency-overdue {
|
||||||
|
background: rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recency-never {
|
||||||
|
background: rgba(107, 114, 128, 0.3);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -28,6 +29,15 @@
|
|||||||
{% include "partials/volume_chart.html" %}
|
{% include "partials/volume_chart.html" %}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
{% include "partials/recent_activity.html" %}
|
||||||
|
|
||||||
|
<!-- Progression Timeline -->
|
||||||
|
{% include "partials/progression_chart.html" %}
|
||||||
|
|
||||||
|
<!-- Muscle Group Heatmap -->
|
||||||
|
{% include "partials/muscle_heatmap.html" %}
|
||||||
|
|
||||||
<!-- Exercise Progress Links -->
|
<!-- Exercise Progress Links -->
|
||||||
<article>
|
<article>
|
||||||
<header><h3>Per-Exercise Progress</h3></header>
|
<header><h3>Per-Exercise Progress</h3></header>
|
||||||
@@ -42,5 +52,12 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<!-- Export -->
|
||||||
|
{% include "partials/export_form.html" %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Import -->
|
||||||
|
{% include "partials/import_form.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,5 +8,33 @@
|
|||||||
<p>Your open-source workout tracker</p>
|
<p>Your open-source workout tracker</p>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
|
|
||||||
<p>Welcome to SneakySwole. Get started by logging in.</p>
|
{% if profiles %}
|
||||||
|
<h2>Select Profile</h2>
|
||||||
|
<div class="grid">
|
||||||
|
{% for profile in profiles %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<strong>{{ profile.display_name }}</strong>
|
||||||
|
</header>
|
||||||
|
{% if profile.height or profile.weight %}
|
||||||
|
<p>
|
||||||
|
{% if profile.height %}Height: {{ profile.height }}{% endif %}
|
||||||
|
{% if profile.height and profile.weight %} · {% endif %}
|
||||||
|
{% if profile.weight %}Weight: {{ profile.weight }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<footer>
|
||||||
|
<form method="POST" action="/profiles/switch">
|
||||||
|
<input type="hidden" name="profile_id" value="{{ profile.id }}">
|
||||||
|
<button type="submit" class="contrast">Select</button>
|
||||||
|
</form>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>No profiles yet. Create one to get started.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="/profiles/create" role="button" class="secondary">Create New Profile</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}SneakySwole — Login{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<article>
|
|
||||||
<header>
|
|
||||||
<h2>Admin Login</h2>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="flash-error" role="alert">{{ error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="POST" action="/login">
|
|
||||||
<label for="username">Username</label>
|
|
||||||
<input type="text" id="username" name="username"
|
|
||||||
placeholder="Enter username" required autofocus>
|
|
||||||
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<input type="password" id="password" name="password"
|
|
||||||
placeholder="Enter password" required>
|
|
||||||
|
|
||||||
<button type="submit">Login</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
{% endblock %}
|
|
||||||
46
app/templates/pages/profile_create.html
Normal file
46
app/templates/pages/profile_create.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}SneakySwole — Create Profile{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<hgroup>
|
||||||
|
<h1>Create Profile</h1>
|
||||||
|
<p>Set up a new workout profile</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<article aria-label="Error" class="pico-background-red-500">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/profiles/create">
|
||||||
|
<label for="display_name">
|
||||||
|
Display Name <span aria-hidden="true">*</span>
|
||||||
|
<input type="text" id="display_name" name="display_name" required
|
||||||
|
placeholder="e.g. Phillip" value="{{ request.query_params.get('display_name', '') }}">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label for="height">
|
||||||
|
Height
|
||||||
|
<input type="text" id="height" name="height"
|
||||||
|
placeholder="e.g. 6'0"">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label for="weight">
|
||||||
|
Weight
|
||||||
|
<input type="text" id="weight" name="weight"
|
||||||
|
placeholder="e.g. 260 lbs">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label for="goals">
|
||||||
|
Goals
|
||||||
|
<textarea id="goals" name="goals" rows="3"
|
||||||
|
placeholder="e.g. Build strength, improve mobility"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Create Profile</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p><a href="/">← Back to profiles</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}4-Week Schedule -- SneakySwole{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<hgroup>
|
|
||||||
<h1>4-Week Schedule</h1>
|
|
||||||
{% if active_profile %}
|
|
||||||
<p>Schedule for: <strong>{{ active_profile.display_name }}</strong></p>
|
|
||||||
{% else %}
|
|
||||||
<p>No profile selected -- <a href="/profiles">select one</a></p>
|
|
||||||
{% endif %}
|
|
||||||
</hgroup>
|
|
||||||
|
|
||||||
{% for week in weeks %}
|
|
||||||
<article>
|
|
||||||
<header>
|
|
||||||
<h3>Week {{ week.week_number }}</h3>
|
|
||||||
</header>
|
|
||||||
<div class="grid">
|
|
||||||
{% for day in week.days %}
|
|
||||||
<div style="text-align:center;
|
|
||||||
padding:1rem;
|
|
||||||
border-radius:0.5rem;
|
|
||||||
{% if day.is_today %}
|
|
||||||
border: 2px solid var(--pico-primary);
|
|
||||||
{% endif %}
|
|
||||||
{% if day.is_completed %}
|
|
||||||
background: rgba(99, 102, 241, 0.15);
|
|
||||||
{% endif %}">
|
|
||||||
<strong>{{ day.workout_day.name }}</strong>
|
|
||||||
<br>
|
|
||||||
<small>{{ day.date.strftime('%b %d') }}</small>
|
|
||||||
{% if day.is_completed %}
|
|
||||||
<br><mark>Done</mark>
|
|
||||||
{% endif %}
|
|
||||||
{% if day.is_today %}
|
|
||||||
<br><small><strong>Today</strong></small>
|
|
||||||
{% endif %}
|
|
||||||
<br>
|
|
||||||
<a href="/workouts/{{ day.workout_day.name|lower|replace(' ', '-') }}"
|
|
||||||
style="font-size:0.8rem;">
|
|
||||||
Start
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -27,5 +27,5 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<a href="/workouts" role="button" class="outline">Back to All Days</a>
|
<a href="/workouts" role="button" class="outline">Change Workout</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Workout Days — SneakySwole{% endblock %}
|
{% block title %}Workout Now — SneakySwole{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Workout Days</h1>
|
<h1>Workout Now</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% if last_workout_name %}
|
||||||
|
Last workout: <strong>{{ last_workout_name }}</strong> on {{ last_workout_date.strftime('%b %-d') }}
|
||||||
|
{% else %}
|
||||||
|
No workouts yet — start with Push!
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{% for day in days %}
|
{% for day in days %}
|
||||||
<article>
|
<article>
|
||||||
<header>
|
<header>
|
||||||
<h3>Day {{ day.day_number }}: {{ day.name }}</h3>
|
<h3>Day {{ day.day_number }}: {{ day.name }}</h3>
|
||||||
|
{% if day.id == recommended_day_id %}
|
||||||
|
<mark>Recommended Next</mark>
|
||||||
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
<p>{{ day.description }}</p>
|
<p>{{ day.description }}</p>
|
||||||
<footer>
|
<footer>
|
||||||
|
{% if day.id == recommended_day_id %}
|
||||||
<a href="/workouts/{{ day.name|lower|replace(' ', '-') }}"
|
<a href="/workouts/{{ day.name|lower|replace(' ', '-') }}"
|
||||||
role="button">View Workout</a>
|
role="button">Start Workout</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/workouts/{{ day.name|lower|replace(' ', '-') }}"
|
||||||
|
role="button" class="outline">Start Workout</a>
|
||||||
|
{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -7,16 +7,26 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if program %}
|
{% if program %}
|
||||||
<div class="grid">
|
{% set suggestion = suggestions[exercise.id] if suggestions and exercise.id in suggestions else None %}
|
||||||
<div>
|
{% set pos = suggestion.ladder_position if suggestion and suggestion.ladder_position is defined else -1 %}
|
||||||
<small>Week 1</small>
|
{% if pos >= 0 %}
|
||||||
<p>{{ program.wk1_reps }} reps @ {{ program.wk1_weight }}</p>
|
<div style="display: flex; gap: 0.25rem; align-items: center; margin-bottom: 0.75rem;">
|
||||||
</div>
|
{% for step in [6, 8, 10, 12] %}
|
||||||
<div>
|
<div style="flex: 1; text-align: center; padding: 0.35rem 0;
|
||||||
<small>Week 4</small>
|
border-radius: 0.25rem; font-size: 0.85rem; font-weight: 600;
|
||||||
<p>{{ program.wk4_reps }} reps @ {{ program.wk4_weight }}</p>
|
{% if loop.index0 <= pos %}
|
||||||
|
background: var(--pico-primary); color: var(--pico-primary-inverse);
|
||||||
|
{% else %}
|
||||||
|
background: var(--pico-muted-border-color); color: var(--pico-muted-color);
|
||||||
|
{% endif %}">
|
||||||
|
{{ step }}
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<small style="margin-left: 0.5rem; white-space: nowrap;">@ {{ suggestion.suggested_weight }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p><small>Starting weight: {{ program.starting_weight }}</small></p>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if suggestions and suggestions[exercise.id] %}
|
{% if suggestions and suggestions[exercise.id] %}
|
||||||
@@ -36,6 +46,13 @@
|
|||||||
{% set suggested_reps = suggestions[exercise.id].suggested_reps %}
|
{% set suggested_reps = suggestions[exercise.id].suggested_reps %}
|
||||||
{% set suggested_weight = suggestions[exercise.id].suggested_weight %}
|
{% set suggested_weight = suggestions[exercise.id].suggested_weight %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if existing_logs and existing_logs[exercise.id] %}
|
||||||
|
{% set suggested_reps = existing_logs[exercise.id][-1].reps_completed %}
|
||||||
|
{% set suggested_weight = existing_logs[exercise.id][-1].weight_used %}
|
||||||
|
{% elif suggestions and suggestions[exercise.id] %}
|
||||||
|
{% set suggested_reps = suggestions[exercise.id].suggested_reps %}
|
||||||
|
{% set suggested_weight = suggestions[exercise.id].suggested_weight %}
|
||||||
|
{% endif %}
|
||||||
{% if existing_logs and existing_logs[exercise.id] %}
|
{% if existing_logs and existing_logs[exercise.id] %}
|
||||||
{% set logs = existing_logs[exercise.id] %}
|
{% set logs = existing_logs[exercise.id] %}
|
||||||
{% set exercise_id = exercise.id %}
|
{% set exercise_id = exercise.id %}
|
||||||
|
|||||||
19
app/templates/partials/export_form.html
Normal file
19
app/templates/partials/export_form.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<article>
|
||||||
|
<header><h3>Export Workout History</h3></header>
|
||||||
|
<form method="get" action="/dashboard/export">
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
Start Date
|
||||||
|
<input type="date" name="start_date" value="{{ export_start_date }}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
End Date
|
||||||
|
<input type="date" name="end_date" value="{{ export_end_date }}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
|
||||||
|
<button type="submit">Download CSV</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
17
app/templates/partials/import_form.html
Normal file
17
app/templates/partials/import_form.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<article>
|
||||||
|
<header><h3>Import Old Database</h3></header>
|
||||||
|
<form hx-post="/dashboard/import" hx-encoding="multipart/form-data"
|
||||||
|
hx-target="#import-results" hx-swap="innerHTML">
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
Old Database File (.db)
|
||||||
|
<input type="file" name="db_file" accept=".db" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
|
||||||
|
<button type="submit">Import</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="import-results"></div>
|
||||||
|
</article>
|
||||||
37
app/templates/partials/import_results.html
Normal file
37
app/templates/partials/import_results.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Imported</th>
|
||||||
|
<th>Skipped</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Workout Sessions</td>
|
||||||
|
<td>{{ result.sessions_imported }}</td>
|
||||||
|
<td>{{ result.sessions_skipped }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Workout Logs</td>
|
||||||
|
<td>{{ result.logs_imported }}</td>
|
||||||
|
<td>{{ result.logs_skipped }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Progress Log</td>
|
||||||
|
<td>{{ result.progress_imported }}</td>
|
||||||
|
<td>{{ result.progress_skipped }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if result.warnings %}
|
||||||
|
<details>
|
||||||
|
<summary>Warnings ({{ result.warnings|length }})</summary>
|
||||||
|
<ul>
|
||||||
|
{% for warning in result.warnings %}
|
||||||
|
<li><small>{{ warning }}</small></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
@@ -13,9 +13,13 @@
|
|||||||
min="0" max="100" required
|
min="0" max="100" required
|
||||||
{% if suggested_reps %}value="{{ suggested_reps }}"{% endif %}
|
{% if suggested_reps %}value="{{ suggested_reps }}"{% endif %}
|
||||||
style="width:5rem; margin-bottom:0;">
|
style="width:5rem; margin-bottom:0;">
|
||||||
<input type="text" name="weight" placeholder="Weight (lbs)"
|
<input type="number" name="weight" placeholder="Weight (lbs)"
|
||||||
required
|
min="0" max="999" step="0.5" required
|
||||||
{% if suggested_weight %}value="{{ suggested_weight }}"{% endif %}
|
{% if suggested_weight and suggested_weight != "BW" %}
|
||||||
|
value="{{ suggested_weight|replace(' lbs', '') }}"
|
||||||
|
{% elif suggested_weight == "BW" %}
|
||||||
|
value="0"
|
||||||
|
{% endif %}
|
||||||
style="width:8rem; margin-bottom:0;">
|
style="width:8rem; margin-bottom:0;">
|
||||||
<label style="display:flex; align-items:center; gap:0.3rem; margin-bottom:0; white-space:nowrap;">
|
<label style="display:flex; align-items:center; gap:0.3rem; margin-bottom:0; white-space:nowrap;">
|
||||||
<input type="checkbox" name="felt_easy" role="switch" style="margin-bottom:0;">
|
<input type="checkbox" name="felt_easy" role="switch" style="margin-bottom:0;">
|
||||||
|
|||||||
24
app/templates/partials/muscle_heatmap.html
Normal file
24
app/templates/partials/muscle_heatmap.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<article>
|
||||||
|
<header><h3>Muscle Group Heatmap</h3></header>
|
||||||
|
{% if muscle_recency %}
|
||||||
|
<div class="muscle-heatmap">
|
||||||
|
{% for mg in muscle_recency %}
|
||||||
|
<div class="muscle-heatmap-cell {% if mg.days_ago is none %}recency-never{% elif mg.days_ago <= 3 %}recency-fresh{% elif mg.days_ago <= 7 %}recency-ok{% elif mg.days_ago <= 14 %}recency-stale{% else %}recency-overdue{% endif %}">
|
||||||
|
<strong>{{ mg.muscle_group }}</strong>
|
||||||
|
<br>
|
||||||
|
{% if mg.days_ago is none %}
|
||||||
|
<small>Never worked</small>
|
||||||
|
{% elif mg.days_ago == 0 %}
|
||||||
|
<small>Today</small>
|
||||||
|
{% elif mg.days_ago == 1 %}
|
||||||
|
<small>1 day ago</small>
|
||||||
|
{% else %}
|
||||||
|
<small>{{ mg.days_ago }} days ago</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>No exercises found in the library.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
{% set admin = request.state.admin %}
|
|
||||||
{% set profiles = request.state.profiles %}
|
{% set profiles = request.state.profiles %}
|
||||||
{% set active_profile = request.state.active_profile %}
|
{% set active_profile = request.state.active_profile %}
|
||||||
{% if admin %}
|
|
||||||
<li>
|
<li>
|
||||||
<details class="dropdown">
|
<details class="dropdown">
|
||||||
<summary>
|
<summary>
|
||||||
@@ -26,13 +24,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="/workouts">Workouts</a></li>
|
<li><a href="/">Home</a></li>
|
||||||
<li><a href="/schedule">Schedule</a></li>
|
|
||||||
<li><a href="/dashboard">Dashboard</a></li>
|
<li><a href="/dashboard">Dashboard</a></li>
|
||||||
|
<li><a href="/workouts">Workouts</a></li>
|
||||||
<li><a href="/history">History</a></li>
|
<li><a href="/history">History</a></li>
|
||||||
<li><a href="/exercises">Exercises</a></li>
|
<li><a href="/exercises">Exercises</a></li>
|
||||||
<li><a href="/profiles">Profiles</a></li>
|
<li><a href="/profiles">Profiles</a></li>
|
||||||
<li><a href="/logout">Logout</a></li>
|
|
||||||
{% else %}
|
|
||||||
<li><a href="/login">Login</a></li>
|
|
||||||
{% endif %}
|
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
{% if suggestion and suggestion.progression_type != "no_program" %}
|
{% if suggestion and suggestion.progression_type != "no_program" %}
|
||||||
|
{% set badge_colors = {
|
||||||
|
"deload": "var(--pico-del-color)",
|
||||||
|
"weight_increase": "var(--pico-ins-color)",
|
||||||
|
"climb": "var(--pico-primary)",
|
||||||
|
"hold": "var(--pico-muted-color)",
|
||||||
|
"hold_at_top": "var(--pico-muted-color)",
|
||||||
|
"baseline": "var(--pico-primary)",
|
||||||
|
} %}
|
||||||
|
{% set border_color = badge_colors.get(suggestion.progression_type, "var(--pico-primary)") %}
|
||||||
<div style="background: rgba(99, 102, 241, 0.1);
|
<div style="background: rgba(99, 102, 241, 0.1);
|
||||||
border-left: 3px solid var(--pico-primary);
|
border-left: 3px solid {{ border_color }};
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
border-radius: 0 0.25rem 0.25rem 0;">
|
border-radius: 0 0.25rem 0.25rem 0;">
|
||||||
|
|||||||
90
app/templates/partials/progression_chart.html
Normal file
90
app/templates/partials/progression_chart.html
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<article>
|
||||||
|
<header><h3>Progression Timeline</h3></header>
|
||||||
|
<div id="progression-container">
|
||||||
|
<canvas id="progression-chart" style="max-height:350px;"></canvas>
|
||||||
|
<div style="margin-top:0.5rem;">
|
||||||
|
<label for="progression-filter" style="display:inline; margin-right:0.5rem;">Exercise:</label>
|
||||||
|
<select id="progression-filter" style="display:inline-block; width:auto;">
|
||||||
|
<option value="all">All Exercises</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="progression-empty" style="display:none;">No progression data yet.</p>
|
||||||
|
</article>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var timeline = {{ progression_timeline_json|safe }};
|
||||||
|
var exerciseData = timeline.exercises || {};
|
||||||
|
var names = Object.keys(exerciseData);
|
||||||
|
|
||||||
|
if (names.length === 0) {
|
||||||
|
document.getElementById('progression-container').style.display = 'none';
|
||||||
|
document.getElementById('progression-empty').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var colors = [
|
||||||
|
'rgba(99, 102, 241, 1)',
|
||||||
|
'rgba(34, 197, 94, 1)',
|
||||||
|
'rgba(234, 179, 8, 1)',
|
||||||
|
'rgba(249, 115, 22, 1)',
|
||||||
|
'rgba(239, 68, 68, 1)',
|
||||||
|
'rgba(168, 85, 247, 1)',
|
||||||
|
'rgba(20, 184, 166, 1)',
|
||||||
|
'rgba(236, 72, 153, 1)',
|
||||||
|
];
|
||||||
|
|
||||||
|
var datasets = [];
|
||||||
|
var filterSelect = document.getElementById('progression-filter');
|
||||||
|
|
||||||
|
names.forEach(function(name, i) {
|
||||||
|
var d = exerciseData[name];
|
||||||
|
datasets.push({
|
||||||
|
label: name,
|
||||||
|
data: d.dates.map(function(dt, j) {
|
||||||
|
return {x: dt, y: d.weights[j]};
|
||||||
|
}),
|
||||||
|
borderColor: colors[i % colors.length],
|
||||||
|
backgroundColor: colors[i % colors.length].replace('1)', '0.2)'),
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 3,
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = i;
|
||||||
|
opt.textContent = name;
|
||||||
|
filterSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
var chart = new Chart(document.getElementById('progression-chart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {datasets: datasets},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {labels: {color: '#ccc'}},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'time',
|
||||||
|
time: {unit: 'week'},
|
||||||
|
ticks: {color: '#ccc'},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: false,
|
||||||
|
title: {display: true, text: 'Weight (lbs)', color: '#ccc'},
|
||||||
|
ticks: {color: '#ccc'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
filterSelect.addEventListener('change', function() {
|
||||||
|
var val = this.value;
|
||||||
|
chart.data.datasets.forEach(function(ds, i) {
|
||||||
|
ds.hidden = (val !== 'all' && i !== parseInt(val));
|
||||||
|
});
|
||||||
|
chart.update();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
29
app/templates/partials/recent_activity.html
Normal file
29
app/templates/partials/recent_activity.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<article>
|
||||||
|
<header><h3>Recent Activity</h3></header>
|
||||||
|
{% if recent_activity %}
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Workout</th>
|
||||||
|
<th>Volume (lbs)</th>
|
||||||
|
<th>Sets</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for session in recent_activity %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ session.date.strftime('%b %d, %Y') }}</td>
|
||||||
|
<td>{{ session.workout_day_name }}</td>
|
||||||
|
<td>{{ "{:,}".format(session.total_volume) }}</td>
|
||||||
|
<td>{{ session.total_sets }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>No workout sessions logged yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
@@ -4,12 +4,6 @@
|
|||||||
{{ stats.total_sessions }}
|
{{ stats.total_sessions }}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article>
|
|
||||||
<header><h4>Total Volume</h4></header>
|
|
||||||
<p style="font-size:2rem; font-weight:700;">
|
|
||||||
{{ "{:,}".format(stats.total_volume) }} lbs
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article>
|
<article>
|
||||||
<header><h4>Total Sets</h4></header>
|
<header><h4>Total Sets</h4></header>
|
||||||
<p style="font-size:2rem; font-weight:700;">
|
<p style="font-size:2rem; font-weight:700;">
|
||||||
@@ -22,3 +16,38 @@
|
|||||||
{{ stats.current_streak }} week{{ "s" if stats.current_streak != 1 }}
|
{{ stats.current_streak }} week{{ "s" if stats.current_streak != 1 }}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<article>
|
||||||
|
<header><h4>Last Workout</h4></header>
|
||||||
|
<p style="font-size:2rem; font-weight:700;">
|
||||||
|
{% if stats.last_workout_date %}
|
||||||
|
{{ stats.last_workout_date.strftime('%b %d') }}
|
||||||
|
{% else %}
|
||||||
|
Never
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<header><h4>Personal Records</h4></header>
|
||||||
|
<p style="font-size:2rem; font-weight:700;">
|
||||||
|
{{ personal_records|length }} PR{{ "s" if personal_records|length != 1 }}
|
||||||
|
</p>
|
||||||
|
{% if personal_records %}
|
||||||
|
<details>
|
||||||
|
<summary>View records</summary>
|
||||||
|
<ul style="font-size:0.85rem; margin-top:0.5rem;">
|
||||||
|
{% for pr in personal_records[:10] %}
|
||||||
|
<li>{{ pr.exercise_name }}: {{ pr.weight_display }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<header><h4>Adherence</h4></header>
|
||||||
|
<p style="font-size:2rem; font-weight:700;">
|
||||||
|
{{ adherence.rate }}%
|
||||||
|
</p>
|
||||||
|
<small>{{ adherence.completed }}/{{ adherence.expected }} sessions ({{ adherence.weeks }}wk)</small>
|
||||||
|
</article>
|
||||||
|
|||||||
@@ -1,69 +1,27 @@
|
|||||||
"""Authentication dependencies for FastAPI route protection.
|
"""Profile selection utilities for FastAPI route protection.
|
||||||
|
|
||||||
Provides dependency functions that verify the admin session cookie
|
Provides dependency functions that check the active_profile_id cookie
|
||||||
and return the authenticated User, or redirect to /login.
|
and return the selected User profile, or redirect to /.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import Depends, Request
|
from fastapi import Depends, Request
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.database import get_db_session
|
from app.database import get_db_session
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.auth_service import AuthService
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class NotAuthenticatedError(Exception):
|
class NoProfileSelectedError(Exception):
|
||||||
"""Raised when a request lacks valid authentication."""
|
"""Raised when a request lacks a valid profile selection."""
|
||||||
|
|
||||||
# Cookie name for the admin session
|
|
||||||
SESSION_COOKIE_NAME = "session"
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_admin_user(request: Request, session: Session = Depends(get_db_session)) -> User:
|
|
||||||
"""FastAPI dependency that extracts and validates the admin session.
|
|
||||||
|
|
||||||
Reads the session cookie, validates the token, and returns the
|
|
||||||
authenticated admin User. Redirects to /login (303) if not authenticated.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The incoming HTTP request.
|
|
||||||
session: Database session (injected by FastAPI).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The authenticated admin User.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RedirectResponse: 303 redirect to /login if no valid admin session.
|
|
||||||
"""
|
|
||||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
|
||||||
if not token:
|
|
||||||
raise _login_redirect()
|
|
||||||
|
|
||||||
secret_key = getattr(request.app.state, "secret_key", "")
|
|
||||||
auth_service = AuthService(session, secret_key=secret_key)
|
|
||||||
user_id = auth_service.validate_session_token(token)
|
|
||||||
|
|
||||||
if user_id is None:
|
|
||||||
raise _login_redirect()
|
|
||||||
|
|
||||||
user = session.get(User, user_id)
|
|
||||||
if user is None or not user.is_admin:
|
|
||||||
raise _login_redirect()
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def get_active_profile_id(request: Request) -> Optional[int]:
|
def get_active_profile_id(request: Request) -> Optional[int]:
|
||||||
"""Extract the active profile ID from the session cookie.
|
"""Extract the active profile ID from the cookie.
|
||||||
|
|
||||||
The admin selects which user profile to view/log as. This is stored
|
|
||||||
in a separate cookie called 'active_profile_id'.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The incoming HTTP request.
|
request: The incoming HTTP request.
|
||||||
@@ -77,11 +35,28 @@ def get_active_profile_id(request: Request) -> Optional[int]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _login_redirect():
|
def require_active_profile(request: Request, session: Session = Depends(get_db_session)) -> User:
|
||||||
"""Create a redirect exception to the login page.
|
"""FastAPI dependency that requires a valid profile selection.
|
||||||
|
|
||||||
|
Reads the active_profile_id cookie, loads the profile from DB,
|
||||||
|
and raises NoProfileSelectedError if missing or invalid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session (injected by FastAPI).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A NotAuthenticatedError handled by a registered exception handler
|
The selected User profile.
|
||||||
in main.py that sends a 302 redirect to /login.
|
|
||||||
|
Raises:
|
||||||
|
NoProfileSelectedError: If no valid profile is selected.
|
||||||
"""
|
"""
|
||||||
return NotAuthenticatedError()
|
profile_id = get_active_profile_id(request)
|
||||||
|
if profile_id is None:
|
||||||
|
raise NoProfileSelectedError()
|
||||||
|
|
||||||
|
user = session.get(User, profile_id)
|
||||||
|
if user is None:
|
||||||
|
raise NoProfileSelectedError()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# SneakySwole User Programs
|
# SneakySwole User Programs
|
||||||
# Per-user exercise programming with week 1 and week 4 targets.
|
# Per-user exercise programming with starting weights for rep ladder progression.
|
||||||
|
# Rep ladder: 6 → 8 → 10 → 12 reps at current weight, then +5 lbs and reset.
|
||||||
# Update this file to adjust programming, then re-run the seed script.
|
# Update this file to adjust programming, then re-run the seed script.
|
||||||
|
|
||||||
programs:
|
programs:
|
||||||
@@ -11,111 +12,51 @@ programs:
|
|||||||
exercises:
|
exercises:
|
||||||
# Day 1 — Push
|
# Day 1 — Push
|
||||||
- name: "DB Chest Press (Floor)"
|
- name: "DB Chest Press (Floor)"
|
||||||
wk1_reps: 8
|
starting_weight: "30 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "30 lbs"
|
|
||||||
wk4_weight: "40 lbs"
|
|
||||||
- name: "DB Shoulder Press (Seated)"
|
- name: "DB Shoulder Press (Seated)"
|
||||||
wk1_reps: 8
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
- name: "DB Lateral Raise"
|
- name: "DB Lateral Raise"
|
||||||
wk1_reps: 10
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "15 lbs"
|
|
||||||
- name: "Push-Up (Incline if needed)"
|
- name: "Push-Up (Incline if needed)"
|
||||||
wk1_reps: 6
|
starting_weight: "BW"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "BW"
|
|
||||||
wk4_weight: "BW"
|
|
||||||
- name: "DB Tricep Overhead Ext."
|
- name: "DB Tricep Overhead Ext."
|
||||||
wk1_reps: 10
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
|
|
||||||
# Day 2 — Pull
|
# Day 2 — Pull
|
||||||
- name: "DB Bent-Over Row (Supported)"
|
- name: "DB Bent-Over Row (Supported)"
|
||||||
wk1_reps: 8
|
starting_weight: "30 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "30 lbs"
|
|
||||||
wk4_weight: "45 lbs"
|
|
||||||
- name: "DB Rear Delt Fly"
|
- name: "DB Rear Delt Fly"
|
||||||
wk1_reps: 10
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "15 lbs"
|
|
||||||
- name: "DB Hammer Curl"
|
- name: "DB Hammer Curl"
|
||||||
wk1_reps: 10
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
- name: "DB Bicep Curl"
|
- name: "DB Bicep Curl"
|
||||||
wk1_reps: 10
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
- name: "DB Shrug"
|
- name: "DB Shrug"
|
||||||
wk1_reps: 12
|
starting_weight: "35 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "35 lbs"
|
|
||||||
wk4_weight: "50 lbs"
|
|
||||||
|
|
||||||
# Day 3 — Lower
|
# Day 3 — Lower
|
||||||
- name: "Goblet Squat (DB)"
|
- name: "Goblet Squat (DB)"
|
||||||
wk1_reps: 8
|
starting_weight: "25 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "25 lbs"
|
|
||||||
wk4_weight: "40 lbs"
|
|
||||||
- name: "Romanian Deadlift (DB)"
|
- name: "Romanian Deadlift (DB)"
|
||||||
wk1_reps: 8
|
starting_weight: "25 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "25 lbs"
|
|
||||||
wk4_weight: "40 lbs"
|
|
||||||
- name: "Reverse Lunge (DB)"
|
- name: "Reverse Lunge (DB)"
|
||||||
wk1_reps: 8
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
- name: "Glute Bridge (DB on hips)"
|
- name: "Glute Bridge (DB on hips)"
|
||||||
wk1_reps: 12
|
starting_weight: "25 lbs"
|
||||||
wk4_reps: 20
|
|
||||||
wk1_weight: "25 lbs"
|
|
||||||
wk4_weight: "40 lbs"
|
|
||||||
- name: "Standing Calf Raise (DB)"
|
- name: "Standing Calf Raise (DB)"
|
||||||
wk1_reps: 15
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 25
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
|
|
||||||
# Day 4 — Full Body
|
# Day 4 — Full Body
|
||||||
- name: "DB Thruster (Squat + Press)"
|
- name: "DB Thruster (Squat + Press)"
|
||||||
wk1_reps: 6
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 10
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
- name: "DB Renegade Row"
|
- name: "DB Renegade Row"
|
||||||
wk1_reps: 6
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 10
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
- name: "DB Rev. Lunge + Curl"
|
- name: "DB Rev. Lunge + Curl"
|
||||||
wk1_reps: 6
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 10
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
- name: "Dead Bug (BW)"
|
- name: "Dead Bug (BW)"
|
||||||
wk1_reps: 6
|
starting_weight: "BW"
|
||||||
wk4_reps: 10
|
|
||||||
wk1_weight: "BW"
|
|
||||||
wk4_weight: "BW"
|
|
||||||
- name: "DB Farmer's Carry"
|
- name: "DB Farmer's Carry"
|
||||||
wk1_reps: "30 sec"
|
starting_weight: "30 lbs"
|
||||||
wk4_reps: "45 sec"
|
|
||||||
wk1_weight: "30 lbs"
|
|
||||||
wk4_weight: "45 lbs"
|
|
||||||
|
|
||||||
- user: "Daughter"
|
- user: "Daughter"
|
||||||
profile:
|
profile:
|
||||||
@@ -125,108 +66,48 @@ programs:
|
|||||||
exercises:
|
exercises:
|
||||||
# Day 1 — Push
|
# Day 1 — Push
|
||||||
- name: "DB Chest Press (Floor)"
|
- name: "DB Chest Press (Floor)"
|
||||||
wk1_reps: 10
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
- name: "DB Shoulder Press (Seated)"
|
- name: "DB Shoulder Press (Seated)"
|
||||||
wk1_reps: 10
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "20 lbs"
|
|
||||||
- name: "DB Lateral Raise"
|
- name: "DB Lateral Raise"
|
||||||
wk1_reps: 12
|
starting_weight: "8 lbs"
|
||||||
wk4_reps: 18
|
|
||||||
wk1_weight: "8 lbs"
|
|
||||||
wk4_weight: "12 lbs"
|
|
||||||
- name: "Push-Up (Incline if needed)"
|
- name: "Push-Up (Incline if needed)"
|
||||||
wk1_reps: 8
|
starting_weight: "BW"
|
||||||
wk4_reps: 20
|
|
||||||
wk1_weight: "BW"
|
|
||||||
wk4_weight: "BW"
|
|
||||||
- name: "DB Tricep Overhead Ext."
|
- name: "DB Tricep Overhead Ext."
|
||||||
wk1_reps: 12
|
starting_weight: "8 lbs"
|
||||||
wk4_reps: 18
|
|
||||||
wk1_weight: "8 lbs"
|
|
||||||
wk4_weight: "15 lbs"
|
|
||||||
|
|
||||||
# Day 2 — Pull
|
# Day 2 — Pull
|
||||||
- name: "DB Bent-Over Row (Supported)"
|
- name: "DB Bent-Over Row (Supported)"
|
||||||
wk1_reps: 10
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
- name: "DB Rear Delt Fly"
|
- name: "DB Rear Delt Fly"
|
||||||
wk1_reps: 12
|
starting_weight: "8 lbs"
|
||||||
wk4_reps: 18
|
|
||||||
wk1_weight: "8 lbs"
|
|
||||||
wk4_weight: "12 lbs"
|
|
||||||
- name: "DB Hammer Curl"
|
- name: "DB Hammer Curl"
|
||||||
wk1_reps: 12
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 18
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "20 lbs"
|
|
||||||
- name: "DB Bicep Curl"
|
- name: "DB Bicep Curl"
|
||||||
wk1_reps: 12
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 18
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "20 lbs"
|
|
||||||
- name: "DB Shrug"
|
- name: "DB Shrug"
|
||||||
wk1_reps: 12
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 18
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "35 lbs"
|
|
||||||
|
|
||||||
# Day 3 — Lower
|
# Day 3 — Lower
|
||||||
- name: "Goblet Squat (DB)"
|
- name: "Goblet Squat (DB)"
|
||||||
wk1_reps: 12
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 20
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
- name: "Romanian Deadlift (DB)"
|
- name: "Romanian Deadlift (DB)"
|
||||||
wk1_reps: 10
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
- name: "Reverse Lunge (DB)"
|
- name: "Reverse Lunge (DB)"
|
||||||
wk1_reps: 10
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "20 lbs"
|
|
||||||
- name: "Glute Bridge (DB on hips)"
|
- name: "Glute Bridge (DB on hips)"
|
||||||
wk1_reps: 15
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 25
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
- name: "Standing Calf Raise (DB)"
|
- name: "Standing Calf Raise (DB)"
|
||||||
wk1_reps: 20
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 30
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
|
|
||||||
# Day 4 — Full Body
|
# Day 4 — Full Body
|
||||||
- name: "DB Thruster (Squat + Press)"
|
- name: "DB Thruster (Squat + Press)"
|
||||||
wk1_reps: 8
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "20 lbs"
|
|
||||||
- name: "DB Renegade Row"
|
- name: "DB Renegade Row"
|
||||||
wk1_reps: 8
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "20 lbs"
|
|
||||||
- name: "DB Rev. Lunge + Curl"
|
- name: "DB Rev. Lunge + Curl"
|
||||||
wk1_reps: 8
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "18 lbs"
|
|
||||||
- name: "Dead Bug (BW)"
|
- name: "Dead Bug (BW)"
|
||||||
wk1_reps: 8
|
starting_weight: "BW"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "BW"
|
|
||||||
wk4_weight: "BW"
|
|
||||||
- name: "DB Farmer's Carry"
|
- name: "DB Farmer's Carry"
|
||||||
wk1_reps: "30 sec"
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: "45 sec"
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
services:
|
services:
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: sneakyswole-proxy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
networks:
|
||||||
|
- sneakyswole
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: git.sneakygeek.net/sneakygeek/sneakyswole:latest
|
image: git.sneakygeek.net/sneakygeek/sneakyswole:latest
|
||||||
container_name: sneakyswole
|
container_name: sneakyswole
|
||||||
ports:
|
expose:
|
||||||
- "${APP_PORT:-8000}:8000"
|
- "8000"
|
||||||
volumes:
|
volumes:
|
||||||
- sneakyswole-data:/app/data
|
- sneakyswole-data:/app/data
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
networks:
|
||||||
|
- sneakyswole
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
@@ -16,6 +32,12 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
sneakyswole:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
sneakyswole-data:
|
sneakyswole-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
caddy-data:
|
||||||
|
driver: local
|
||||||
|
|||||||
@@ -1,30 +1,12 @@
|
|||||||
# SneakySwole Roadmap
|
# SneakySwole Roadmap
|
||||||
|
|
||||||
## Completed
|
## V3 Improvements
|
||||||
|
|
||||||
### Phase 1: Scaffold & Infrastructure
|
Each improvement is implemented as a separate branch/PR. Implementation order matters — auth removal goes first since other features depend on the simplified profile flow.
|
||||||
FastAPI project structure, Dockerfile + docker-compose.yaml, Pico CSS dark theme base template, `.env` config, structlog logging, health check endpoint.
|
|
||||||
|
|
||||||
### Phase 2: Data Layer & Seeding
|
|
||||||
8-table SQLite schema via SQLModel + Alembic migrations. YAML-driven seed script (`config/exercises.yaml`, `config/user_programs.yaml`). Service layer for all DB access. Admin user auto-created from `.env` with bcrypt.
|
|
||||||
|
|
||||||
### Phase 3: Workout UI
|
|
||||||
Admin login (bcrypt + signed session cookies), profile switcher, workout day viewer with warmups and exercise cards, HTMX-powered exercise browser with search/filter. NavContextMiddleware for automatic template context.
|
|
||||||
|
|
||||||
### Phase 4: Logging & Tracking
|
|
||||||
Inline set logging from the workout day view via HTMX. Auto-created workout sessions. Log history with per-session detail view. Edit and delete log entries.
|
|
||||||
|
|
||||||
### Phase 5: Progression & Analytics
|
|
||||||
Auto-progression engine (+reps/+weight/deload rules), 4-week schedule calendar, progress dashboard with Chart.js charts, per-exercise progress pages with suggestions.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Future Ideas
|
## Future Ideas
|
||||||
|
|
||||||
- Multi-user login (replace profile switcher with individual logins)
|
|
||||||
- REST API for mobile clients
|
|
||||||
- Exercise video/image attachments
|
- Exercise video/image attachments
|
||||||
- Custom workout program builder
|
- Custom workout program builder
|
||||||
- Export/import workout data (CSV/JSON)
|
|
||||||
- Notifications/reminders
|
|
||||||
- Social features (sharing workouts)
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ uvicorn[standard]>=0.34.0,<1.0.0
|
|||||||
jinja2>=3.1.0,<4.0.0
|
jinja2>=3.1.0,<4.0.0
|
||||||
structlog>=24.0.0,<25.0.0
|
structlog>=24.0.0,<25.0.0
|
||||||
python-dotenv>=1.0.0,<2.0.0
|
python-dotenv>=1.0.0,<2.0.0
|
||||||
bcrypt>=4.2.0,<5.0.0
|
|
||||||
itsdangerous>=2.2.0,<3.0.0
|
|
||||||
python-multipart>=0.0.20,<1.0.0
|
python-multipart>=0.0.20,<1.0.0
|
||||||
pyyaml>=6.0.0,<7.0.0
|
pyyaml>=6.0.0,<7.0.0
|
||||||
sqlmodel>=0.0.22,<1.0.0
|
sqlmodel>=0.0.22,<1.0.0
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ from fastapi.testclient import TestClient
|
|||||||
def _set_test_env() -> None:
|
def _set_test_env() -> None:
|
||||||
"""Ensure required env vars are set for all tests."""
|
"""Ensure required env vars are set for all tests."""
|
||||||
with patch.dict(os.environ, {
|
with patch.dict(os.environ, {
|
||||||
"ADMIN_USERNAME": "testadmin",
|
|
||||||
"ADMIN_PASSWORD": "testpass123",
|
|
||||||
"APP_ENV": "development",
|
"APP_ENV": "development",
|
||||||
"DATABASE_URL": "sqlite:///data/test_sneakyswole.db",
|
"DATABASE_URL": "sqlite:///data/test_sneakyswole.db",
|
||||||
}, clear=False):
|
}, clear=False):
|
||||||
@@ -26,3 +24,8 @@ def client() -> TestClient:
|
|||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def set_profile_cookie(client: TestClient, profile_id: int) -> None:
|
||||||
|
"""Helper to set the active_profile_id cookie on a test client."""
|
||||||
|
client.cookies.set("active_profile_id", str(profile_id))
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class TestAnalyticsService:
|
|||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
session = Session(engine)
|
session = Session(engine)
|
||||||
|
|
||||||
user = User(username="phil", password_hash="h", display_name="Phillip")
|
user = User(username="phil", display_name="Phillip")
|
||||||
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
||||||
exercise = Exercise(
|
exercise = Exercise(
|
||||||
name="DB Chest Press", muscle_group="Chest",
|
name="DB Chest Press", muscle_group="Chest",
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
"""Tests for the auth dependency (require_admin)."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.utils.auth import (
|
|
||||||
NotAuthenticatedError,
|
|
||||||
get_current_admin_user,
|
|
||||||
get_active_profile_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAuthDependency:
|
|
||||||
"""Tests for the require_admin dependency."""
|
|
||||||
|
|
||||||
def test_redirects_when_no_session_cookie(self) -> None:
|
|
||||||
"""Should raise NotAuthenticatedError when no session cookie is present."""
|
|
||||||
request = MagicMock()
|
|
||||||
request.cookies = {}
|
|
||||||
|
|
||||||
with pytest.raises(NotAuthenticatedError):
|
|
||||||
get_current_admin_user(request=request, session=MagicMock())
|
|
||||||
|
|
||||||
def test_redirects_when_invalid_token(self) -> None:
|
|
||||||
"""Should raise NotAuthenticatedError when session cookie has invalid token."""
|
|
||||||
request = MagicMock()
|
|
||||||
request.cookies = {"session": "invalid-token"}
|
|
||||||
request.app.state.secret_key = "test-secret"
|
|
||||||
|
|
||||||
mock_session = MagicMock()
|
|
||||||
mock_session.get.return_value = None
|
|
||||||
|
|
||||||
with pytest.raises(NotAuthenticatedError):
|
|
||||||
get_current_admin_user(request=request, session=mock_session)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetActiveProfileId:
|
|
||||||
"""Tests for the get_active_profile_id dependency."""
|
|
||||||
|
|
||||||
def test_returns_profile_id_from_cookie(self) -> None:
|
|
||||||
"""Should return the integer profile ID from cookie."""
|
|
||||||
request = MagicMock()
|
|
||||||
request.cookies = {"active_profile_id": "5"}
|
|
||||||
assert get_active_profile_id(request) == 5
|
|
||||||
|
|
||||||
def test_returns_none_when_no_cookie(self) -> None:
|
|
||||||
"""Should return None when no active_profile_id cookie is set."""
|
|
||||||
request = MagicMock()
|
|
||||||
request.cookies = {}
|
|
||||||
assert get_active_profile_id(request) is None
|
|
||||||
|
|
||||||
def test_returns_none_for_non_numeric(self) -> None:
|
|
||||||
"""Should return None for non-numeric cookie values."""
|
|
||||||
request = MagicMock()
|
|
||||||
request.cookies = {"active_profile_id": "abc"}
|
|
||||||
assert get_active_profile_id(request) is None
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""Tests for login/logout routes."""
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoginPage:
|
|
||||||
"""Tests for GET /login."""
|
|
||||||
|
|
||||||
def test_login_page_returns_200(self, client: TestClient) -> None:
|
|
||||||
"""GET /login should render the login form."""
|
|
||||||
response = client.get("/login")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Login" in response.text
|
|
||||||
|
|
||||||
def test_login_page_has_form(self, client: TestClient) -> None:
|
|
||||||
"""GET /login should contain a login form with username and password."""
|
|
||||||
response = client.get("/login")
|
|
||||||
assert 'name="username"' in response.text
|
|
||||||
assert 'name="password"' in response.text
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoginPost:
|
|
||||||
"""Tests for POST /login."""
|
|
||||||
|
|
||||||
def test_login_invalid_credentials_shows_error(self, client: TestClient) -> None:
|
|
||||||
"""POST /login with wrong credentials should show error message."""
|
|
||||||
response = client.post(
|
|
||||||
"/login",
|
|
||||||
data={"username": "wrong", "password": "wrong"},
|
|
||||||
follow_redirects=False,
|
|
||||||
)
|
|
||||||
# Should re-render login page with error or redirect back
|
|
||||||
assert response.status_code in (200, 303)
|
|
||||||
|
|
||||||
|
|
||||||
class TestLogout:
|
|
||||||
"""Tests for GET /logout."""
|
|
||||||
|
|
||||||
def test_logout_redirects_to_login(self, client: TestClient) -> None:
|
|
||||||
"""GET /logout should clear session and redirect to login."""
|
|
||||||
response = client.get("/logout", follow_redirects=False)
|
|
||||||
assert response.status_code in (303, 307)
|
|
||||||
assert "/login" in response.headers.get("location", "")
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"""Tests for the AuthService class."""
|
|
||||||
|
|
||||||
import bcrypt
|
|
||||||
from sqlmodel import SQLModel, Session, create_engine
|
|
||||||
|
|
||||||
from app.models.user import User
|
|
||||||
from app.services.auth_service import AuthService
|
|
||||||
|
|
||||||
|
|
||||||
class TestAuthService:
|
|
||||||
"""Tests for admin authentication logic."""
|
|
||||||
|
|
||||||
def _setup(self):
|
|
||||||
"""Create an in-memory DB with an admin user."""
|
|
||||||
engine = create_engine("sqlite:///:memory:")
|
|
||||||
SQLModel.metadata.create_all(engine)
|
|
||||||
session = Session(engine)
|
|
||||||
|
|
||||||
# Create admin user with known password
|
|
||||||
pw_hash = bcrypt.hashpw(b"adminpass", bcrypt.gensalt()).decode("utf-8")
|
|
||||||
admin = User(
|
|
||||||
username="admin",
|
|
||||||
password_hash=pw_hash,
|
|
||||||
display_name="Admin",
|
|
||||||
is_admin=True,
|
|
||||||
)
|
|
||||||
session.add(admin)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
service = AuthService(session, secret_key="test-secret-key")
|
|
||||||
return session, service
|
|
||||||
|
|
||||||
def test_authenticate_valid_credentials(self) -> None:
|
|
||||||
"""authenticate should return the User for valid credentials."""
|
|
||||||
session, service = self._setup()
|
|
||||||
user = service.authenticate("admin", "adminpass")
|
|
||||||
assert user is not None
|
|
||||||
assert user.username == "admin"
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
def test_authenticate_wrong_password(self) -> None:
|
|
||||||
"""authenticate should return None for wrong password."""
|
|
||||||
session, service = self._setup()
|
|
||||||
user = service.authenticate("admin", "wrongpass")
|
|
||||||
assert user is None
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
def test_authenticate_unknown_user(self) -> None:
|
|
||||||
"""authenticate should return None for unknown username."""
|
|
||||||
session, service = self._setup()
|
|
||||||
user = service.authenticate("nobody", "anything")
|
|
||||||
assert user is None
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
def test_authenticate_non_admin(self) -> None:
|
|
||||||
"""authenticate should return None for non-admin users."""
|
|
||||||
session, service = self._setup()
|
|
||||||
pw_hash = bcrypt.hashpw(b"userpass", bcrypt.gensalt()).decode("utf-8")
|
|
||||||
non_admin = User(
|
|
||||||
username="regular",
|
|
||||||
password_hash=pw_hash,
|
|
||||||
display_name="Regular",
|
|
||||||
is_admin=False,
|
|
||||||
)
|
|
||||||
session.add(non_admin)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
user = service.authenticate("regular", "userpass")
|
|
||||||
assert user is None
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
def test_create_session_token(self) -> None:
|
|
||||||
"""create_session_token should return a non-empty signed string."""
|
|
||||||
session, service = self._setup()
|
|
||||||
token = service.create_session_token(user_id=1)
|
|
||||||
assert isinstance(token, str)
|
|
||||||
assert len(token) > 0
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
def test_validate_session_token(self) -> None:
|
|
||||||
"""validate_session_token should return the user_id from a valid token."""
|
|
||||||
session, service = self._setup()
|
|
||||||
token = service.create_session_token(user_id=42)
|
|
||||||
user_id = service.validate_session_token(token)
|
|
||||||
assert user_id == 42
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
def test_validate_session_token_invalid(self) -> None:
|
|
||||||
"""validate_session_token should return None for tampered tokens."""
|
|
||||||
session, service = self._setup()
|
|
||||||
user_id = service.validate_session_token("fake-token-value")
|
|
||||||
assert user_id is None
|
|
||||||
session.close()
|
|
||||||
@@ -11,37 +11,17 @@ class TestSettings:
|
|||||||
|
|
||||||
def test_settings_loads_defaults(self) -> None:
|
def test_settings_loads_defaults(self) -> None:
|
||||||
"""Settings should have sensible defaults for all fields."""
|
"""Settings should have sensible defaults for all fields."""
|
||||||
env = {
|
|
||||||
"ADMIN_USERNAME": "testadmin",
|
|
||||||
"ADMIN_PASSWORD": "testpass123",
|
|
||||||
}
|
|
||||||
# Remove DATABASE_URL so we test the actual default
|
# Remove DATABASE_URL so we test the actual default
|
||||||
env_clean = {k: v for k, v in os.environ.items() if k != "DATABASE_URL"}
|
env_clean = {k: v for k, v in os.environ.items() if k != "DATABASE_URL"}
|
||||||
env_clean.update(env)
|
|
||||||
with patch.dict(os.environ, env_clean, clear=True):
|
with patch.dict(os.environ, env_clean, clear=True):
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
assert settings.admin_username == "testadmin"
|
|
||||||
assert settings.admin_password == "testpass123"
|
|
||||||
assert settings.app_env == "development"
|
assert settings.app_env == "development"
|
||||||
assert settings.app_host == "0.0.0.0"
|
assert settings.app_host == "0.0.0.0"
|
||||||
assert settings.app_port == 8000
|
assert settings.app_port == 8000
|
||||||
assert settings.database_url == "sqlite:///data/sneakyswole.db"
|
assert settings.database_url == "sqlite:///data/sneakyswole.db"
|
||||||
|
|
||||||
def test_settings_requires_admin_username(self) -> None:
|
|
||||||
"""Settings should require ADMIN_USERNAME to be set."""
|
|
||||||
with patch.dict(os.environ, {"ADMIN_PASSWORD": "testpass"}, clear=True):
|
|
||||||
try:
|
|
||||||
Settings()
|
|
||||||
assert False, "Should have raised an error"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_get_settings_returns_singleton(self) -> None:
|
def test_get_settings_returns_singleton(self) -> None:
|
||||||
"""get_settings should return the same instance on repeated calls."""
|
"""get_settings should return the same instance on repeated calls."""
|
||||||
with patch.dict(os.environ, {
|
s1 = get_settings()
|
||||||
"ADMIN_USERNAME": "admin",
|
s2 = get_settings()
|
||||||
"ADMIN_PASSWORD": "pass",
|
assert s1 is s2
|
||||||
}, clear=False):
|
|
||||||
s1 = get_settings()
|
|
||||||
s2 = get_settings()
|
|
||||||
assert s1 is s2
|
|
||||||
|
|||||||
@@ -6,16 +6,18 @@ from fastapi.testclient import TestClient
|
|||||||
class TestDashboard:
|
class TestDashboard:
|
||||||
"""Tests for GET /dashboard."""
|
"""Tests for GET /dashboard."""
|
||||||
|
|
||||||
def test_dashboard_requires_auth(self, client: TestClient) -> None:
|
def test_dashboard_requires_profile(self, client: TestClient) -> None:
|
||||||
"""GET /dashboard should require admin login."""
|
"""GET /dashboard should redirect to / without profile cookie."""
|
||||||
response = client.get("/dashboard", follow_redirects=False)
|
response = client.get("/dashboard", follow_redirects=False)
|
||||||
assert response.status_code in (401, 302, 303)
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|
||||||
|
|
||||||
class TestExerciseProgress:
|
class TestExerciseProgress:
|
||||||
"""Tests for GET /dashboard/exercise/<id>."""
|
"""Tests for GET /dashboard/exercise/<id>."""
|
||||||
|
|
||||||
def test_exercise_progress_requires_auth(self, client: TestClient) -> None:
|
def test_exercise_progress_requires_profile(self, client: TestClient) -> None:
|
||||||
"""GET /dashboard/exercise/1 should require admin login."""
|
"""GET /dashboard/exercise/1 should redirect to / without profile cookie."""
|
||||||
response = client.get("/dashboard/exercise/1", follow_redirects=False)
|
response = client.get("/dashboard/exercise/1", follow_redirects=False)
|
||||||
assert response.status_code in (401, 302, 303)
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|||||||
@@ -6,19 +6,21 @@ from fastapi.testclient import TestClient
|
|||||||
class TestExerciseBrowser:
|
class TestExerciseBrowser:
|
||||||
"""Tests for GET /exercises."""
|
"""Tests for GET /exercises."""
|
||||||
|
|
||||||
def test_exercise_browser_requires_auth(self, client: TestClient) -> None:
|
def test_exercise_browser_requires_profile(self, client: TestClient) -> None:
|
||||||
"""GET /exercises should require admin login."""
|
"""GET /exercises should redirect to / without profile cookie."""
|
||||||
response = client.get("/exercises", follow_redirects=False)
|
response = client.get("/exercises", follow_redirects=False)
|
||||||
assert response.status_code in (401, 302, 303)
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|
||||||
|
|
||||||
class TestExerciseSearch:
|
class TestExerciseSearch:
|
||||||
"""Tests for HTMX exercise search."""
|
"""Tests for HTMX exercise search."""
|
||||||
|
|
||||||
def test_exercise_search_requires_auth(self, client: TestClient) -> None:
|
def test_exercise_search_requires_profile(self, client: TestClient) -> None:
|
||||||
"""GET /exercises/search should require admin login."""
|
"""GET /exercises/search should redirect to / without profile cookie."""
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/exercises/search?workout_day=Push",
|
"/exercises/search?workout_day=Push",
|
||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
)
|
)
|
||||||
assert response.status_code in (401, 302, 303)
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|||||||
@@ -6,16 +6,18 @@ from fastapi.testclient import TestClient
|
|||||||
class TestLogHistory:
|
class TestLogHistory:
|
||||||
"""Tests for GET /history."""
|
"""Tests for GET /history."""
|
||||||
|
|
||||||
def test_history_requires_auth(self, client: TestClient) -> None:
|
def test_history_requires_profile(self, client: TestClient) -> None:
|
||||||
"""GET /history should require admin login."""
|
"""GET /history should redirect to / without profile cookie."""
|
||||||
response = client.get("/history", follow_redirects=False)
|
response = client.get("/history", follow_redirects=False)
|
||||||
assert response.status_code in (401, 302, 303)
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|
||||||
|
|
||||||
class TestSessionDetail:
|
class TestSessionDetail:
|
||||||
"""Tests for GET /history/<session_id>."""
|
"""Tests for GET /history/<session_id>."""
|
||||||
|
|
||||||
def test_session_detail_requires_auth(self, client: TestClient) -> None:
|
def test_session_detail_requires_profile(self, client: TestClient) -> None:
|
||||||
"""GET /history/1 should require admin login."""
|
"""GET /history/1 should redirect to / without profile cookie."""
|
||||||
response = client.get("/history/1", follow_redirects=False)
|
response = client.get("/history/1", follow_redirects=False)
|
||||||
assert response.status_code in (401, 302, 303)
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class TestLogService:
|
|||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
session = Session(engine)
|
session = Session(engine)
|
||||||
|
|
||||||
user = User(username="phil", password_hash="h", display_name="Phillip")
|
user = User(username="phil", display_name="Phillip")
|
||||||
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
||||||
exercise = Exercise(
|
exercise = Exercise(
|
||||||
name="DB Chest Press", muscle_group="Chest",
|
name="DB Chest Press", muscle_group="Chest",
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from fastapi.testclient import TestClient
|
|||||||
class TestLogSet:
|
class TestLogSet:
|
||||||
"""Tests for POST /log."""
|
"""Tests for POST /log."""
|
||||||
|
|
||||||
def test_log_set_requires_auth(self, client: TestClient) -> None:
|
def test_log_set_requires_profile(self, client: TestClient) -> None:
|
||||||
"""POST /log should require admin login."""
|
"""POST /log should redirect to / without profile cookie."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/log",
|
"/log",
|
||||||
data={
|
data={
|
||||||
@@ -19,26 +19,29 @@ class TestLogSet:
|
|||||||
},
|
},
|
||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
)
|
)
|
||||||
assert response.status_code in (401, 302, 303)
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|
||||||
|
|
||||||
class TestLogEdit:
|
class TestLogEdit:
|
||||||
"""Tests for POST /log/<id>/edit."""
|
"""Tests for POST /log/<id>/edit."""
|
||||||
|
|
||||||
def test_edit_log_requires_auth(self, client: TestClient) -> None:
|
def test_edit_log_requires_profile(self, client: TestClient) -> None:
|
||||||
"""POST /log/1/edit should require admin login."""
|
"""POST /log/1/edit should redirect to / without profile cookie."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/log/1/edit",
|
"/log/1/edit",
|
||||||
data={"reps": "10", "weight": "35 lbs"},
|
data={"reps": "10", "weight": "35 lbs"},
|
||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
)
|
)
|
||||||
assert response.status_code in (401, 302, 303)
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|
||||||
|
|
||||||
class TestLogDelete:
|
class TestLogDelete:
|
||||||
"""Tests for POST /log/<id>/delete."""
|
"""Tests for POST /log/<id>/delete."""
|
||||||
|
|
||||||
def test_delete_log_requires_auth(self, client: TestClient) -> None:
|
def test_delete_log_requires_profile(self, client: TestClient) -> None:
|
||||||
"""POST /log/1/delete should require admin login."""
|
"""POST /log/1/delete should redirect to / without profile cookie."""
|
||||||
response = client.post("/log/1/delete", follow_redirects=False)
|
response = client.post("/log/1/delete", follow_redirects=False)
|
||||||
assert response.status_code in (401, 302, 303)
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|||||||
@@ -28,12 +28,10 @@ class TestModels:
|
|||||||
engine = self._create_engine()
|
engine = self._create_engine()
|
||||||
user = User(
|
user = User(
|
||||||
username="testuser",
|
username="testuser",
|
||||||
password_hash="fakehash",
|
display_name="Test User",
|
||||||
display_name="Test User",
|
|
||||||
height="6'0\"",
|
height="6'0\"",
|
||||||
weight="200 lbs",
|
weight="200 lbs",
|
||||||
goals="Get strong",
|
goals="Get strong",
|
||||||
is_admin=False,
|
|
||||||
)
|
)
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
session.add(user)
|
session.add(user)
|
||||||
@@ -41,7 +39,6 @@ class TestModels:
|
|||||||
session.refresh(user)
|
session.refresh(user)
|
||||||
assert user.id is not None
|
assert user.id is not None
|
||||||
assert user.username == "testuser"
|
assert user.username == "testuser"
|
||||||
assert user.is_admin is False
|
|
||||||
assert isinstance(user.created_at, datetime)
|
assert isinstance(user.created_at, datetime)
|
||||||
|
|
||||||
def test_exercise_model_roundtrip(self) -> None:
|
def test_exercise_model_roundtrip(self) -> None:
|
||||||
@@ -97,7 +94,7 @@ class TestModels:
|
|||||||
"""UserExerciseProgram model should persist with FK references."""
|
"""UserExerciseProgram model should persist with FK references."""
|
||||||
engine = self._create_engine()
|
engine = self._create_engine()
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
user = User(username="u", password_hash="h", display_name="U")
|
user = User(username="u", display_name="U")
|
||||||
exercise = Exercise(
|
exercise = Exercise(
|
||||||
name="Test Ex", muscle_group="Test",
|
name="Test Ex", muscle_group="Test",
|
||||||
workout_day="Push", sets=3, tempo="3-1-2", form_cues="..."
|
workout_day="Push", sets=3, tempo="3-1-2", form_cues="..."
|
||||||
@@ -111,10 +108,7 @@ class TestModels:
|
|||||||
program = UserExerciseProgram(
|
program = UserExerciseProgram(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
exercise_id=exercise.id,
|
exercise_id=exercise.id,
|
||||||
wk1_reps="8",
|
starting_weight="30 lbs",
|
||||||
wk4_reps="12",
|
|
||||||
wk1_weight="30 lbs",
|
|
||||||
wk4_weight="40 lbs",
|
|
||||||
)
|
)
|
||||||
session.add(program)
|
session.add(program)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -126,7 +120,7 @@ class TestModels:
|
|||||||
"""WorkoutSession model should persist correctly."""
|
"""WorkoutSession model should persist correctly."""
|
||||||
engine = self._create_engine()
|
engine = self._create_engine()
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
user = User(username="u", password_hash="h", display_name="U")
|
user = User(username="u", display_name="U")
|
||||||
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.add(day)
|
session.add(day)
|
||||||
@@ -148,7 +142,7 @@ class TestModels:
|
|||||||
"""WorkoutLog model should persist correctly."""
|
"""WorkoutLog model should persist correctly."""
|
||||||
engine = self._create_engine()
|
engine = self._create_engine()
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
user = User(username="u", password_hash="h", display_name="U")
|
user = User(username="u", display_name="U")
|
||||||
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
||||||
exercise = Exercise(
|
exercise = Exercise(
|
||||||
name="Ex", muscle_group="Test",
|
name="Ex", muscle_group="Test",
|
||||||
@@ -187,7 +181,7 @@ class TestModels:
|
|||||||
"""ProgressLog model should persist correctly."""
|
"""ProgressLog model should persist correctly."""
|
||||||
engine = self._create_engine()
|
engine = self._create_engine()
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
user = User(username="u", password_hash="h", display_name="U")
|
user = User(username="u", display_name="U")
|
||||||
exercise = Exercise(
|
exercise = Exercise(
|
||||||
name="Ex", muscle_group="Test",
|
name="Ex", muscle_group="Test",
|
||||||
workout_day="Push", sets=3, tempo="3-1-2", form_cues="..."
|
workout_day="Push", sets=3, tempo="3-1-2", form_cues="..."
|
||||||
|
|||||||
105
tests/test_profile_auth.py
Normal file
105
tests/test_profile_auth.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Tests for profile-based access control and landing page."""
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel, Session, create_engine
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.user_service import UserService
|
||||||
|
from tests.conftest import set_profile_cookie
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileAuth:
|
||||||
|
"""Tests for the profile cookie-based access flow."""
|
||||||
|
|
||||||
|
def test_no_profile_redirects_to_home(self, client) -> None:
|
||||||
|
"""Routes requiring a profile should redirect to / when no cookie set."""
|
||||||
|
response = client.get("/workouts", follow_redirects=False)
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|
||||||
|
def test_invalid_profile_redirects_to_home(self, client) -> None:
|
||||||
|
"""An invalid profile_id cookie should redirect to /."""
|
||||||
|
client.cookies.set("active_profile_id", "99999")
|
||||||
|
response = client.get("/workouts", follow_redirects=False)
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|
||||||
|
def test_valid_profile_allows_access(self, client) -> None:
|
||||||
|
"""A valid profile cookie should allow access to protected routes."""
|
||||||
|
# The seeded profiles exist; find one
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Get the first profile ID from the seeded data
|
||||||
|
set_profile_cookie(client, 1)
|
||||||
|
response = client.get("/workouts")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestLandingPage:
|
||||||
|
"""Tests for the home page profile picker."""
|
||||||
|
|
||||||
|
def test_home_page_shows_profiles(self, client) -> None:
|
||||||
|
"""GET / should show seeded profile names."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Select Profile" in response.text
|
||||||
|
|
||||||
|
def test_home_page_accessible_without_cookie(self, client) -> None:
|
||||||
|
"""GET / should work without any profile cookie."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "SneakySwole" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileCreation:
|
||||||
|
"""Tests for the profile creation flow."""
|
||||||
|
|
||||||
|
def test_create_profile_form_renders(self, client) -> None:
|
||||||
|
"""GET /profiles/create should render the form."""
|
||||||
|
response = client.get("/profiles/create")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Display Name" in response.text
|
||||||
|
|
||||||
|
def test_create_profile_sets_cookie(self, client) -> None:
|
||||||
|
"""POST /profiles/create should create profile and set cookie."""
|
||||||
|
response = client.post(
|
||||||
|
"/profiles/create",
|
||||||
|
data={"display_name": "NewUser", "height": "5'10\"", "weight": "180 lbs"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/workouts"
|
||||||
|
assert "active_profile_id" in response.cookies
|
||||||
|
|
||||||
|
def test_create_profile_requires_name(self, client) -> None:
|
||||||
|
"""POST /profiles/create with empty name should show error."""
|
||||||
|
response = client.post(
|
||||||
|
"/profiles/create",
|
||||||
|
data={"display_name": "", "height": "", "weight": ""},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "required" in response.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileSwitch:
|
||||||
|
"""Tests for the profile switch flow."""
|
||||||
|
|
||||||
|
def test_switch_profile_sets_cookie(self, client) -> None:
|
||||||
|
"""POST /profiles/switch should set cookie and redirect."""
|
||||||
|
response = client.post(
|
||||||
|
"/profiles/switch",
|
||||||
|
data={"profile_id": "1"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert "active_profile_id" in response.cookies
|
||||||
|
|
||||||
|
def test_switch_invalid_profile_redirects_home(self, client) -> None:
|
||||||
|
"""POST /profiles/switch with invalid ID should redirect to /."""
|
||||||
|
response = client.post(
|
||||||
|
"/profiles/switch",
|
||||||
|
data={"profile_id": "99999"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
@@ -2,25 +2,34 @@
|
|||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.conftest import set_profile_cookie
|
||||||
|
|
||||||
|
|
||||||
class TestProfileSwitcher:
|
class TestProfileSwitcher:
|
||||||
"""Tests for POST /profiles/switch."""
|
"""Tests for POST /profiles/switch."""
|
||||||
|
|
||||||
def test_switch_profile_requires_auth(self, client: TestClient) -> None:
|
def test_switch_profile_redirects_to_workouts(self, client: TestClient) -> None:
|
||||||
"""POST /profiles/switch should require admin login."""
|
"""POST /profiles/switch should set cookie and redirect to /workouts."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/profiles/switch",
|
"/profiles/switch",
|
||||||
data={"profile_id": "1"},
|
data={"profile_id": "1"},
|
||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
)
|
)
|
||||||
# Should redirect to login or return 401
|
assert response.status_code == 303
|
||||||
assert response.status_code in (401, 302, 303)
|
assert response.headers["location"] == "/workouts"
|
||||||
|
|
||||||
|
|
||||||
class TestProfileList:
|
class TestProfileList:
|
||||||
"""Tests for GET /profiles."""
|
"""Tests for GET /profiles."""
|
||||||
|
|
||||||
def test_profiles_page_requires_auth(self, client: TestClient) -> None:
|
def test_profiles_page_requires_profile(self, client: TestClient) -> None:
|
||||||
"""GET /profiles should require admin login."""
|
"""GET /profiles should redirect to / without profile cookie."""
|
||||||
response = client.get("/profiles", follow_redirects=False)
|
response = client.get("/profiles", follow_redirects=False)
|
||||||
assert response.status_code in (401, 302, 303)
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|
||||||
|
def test_profiles_page_with_profile(self, client: TestClient) -> None:
|
||||||
|
"""GET /profiles should succeed with a valid profile cookie."""
|
||||||
|
set_profile_cookie(client, 1)
|
||||||
|
response = client.get("/profiles")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for the ProgressionService class."""
|
"""Tests for the ProgressionService class (rep ladder model)."""
|
||||||
|
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
@@ -15,15 +15,15 @@ from app.services.progression_service import ProgressionService
|
|||||||
|
|
||||||
|
|
||||||
class TestProgressionService:
|
class TestProgressionService:
|
||||||
"""Tests for the auto-progression engine."""
|
"""Tests for the rep ladder auto-progression engine."""
|
||||||
|
|
||||||
def _setup(self):
|
def _setup(self, starting_weight="30 lbs"):
|
||||||
"""Create an in-memory DB with a user, exercise, and program."""
|
"""Create an in-memory DB with a user, exercise, and program."""
|
||||||
engine = create_engine("sqlite:///:memory:")
|
engine = create_engine("sqlite:///:memory:")
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
session = Session(engine)
|
session = Session(engine)
|
||||||
|
|
||||||
user = User(username="phil", password_hash="h", display_name="Phillip")
|
user = User(username="phil", display_name="Phillip")
|
||||||
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
||||||
exercise = Exercise(
|
exercise = Exercise(
|
||||||
name="DB Chest Press", muscle_group="Chest",
|
name="DB Chest Press", muscle_group="Chest",
|
||||||
@@ -37,8 +37,7 @@ class TestProgressionService:
|
|||||||
|
|
||||||
program = UserExerciseProgram(
|
program = UserExerciseProgram(
|
||||||
user_id=user.id, exercise_id=exercise.id,
|
user_id=user.id, exercise_id=exercise.id,
|
||||||
wk1_reps="8", wk4_reps="12",
|
starting_weight=starting_weight,
|
||||||
wk1_weight="30 lbs", wk4_weight="40 lbs",
|
|
||||||
)
|
)
|
||||||
session.add(program)
|
session.add(program)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -47,101 +46,115 @@ class TestProgressionService:
|
|||||||
service = ProgressionService(session)
|
service = ProgressionService(session)
|
||||||
return session, service, user, day, exercise, program
|
return session, service, user, day, exercise, program
|
||||||
|
|
||||||
def test_suggest_reps_increase(self) -> None:
|
def _log_session(self, session, user, day, exercise, reps, weight, felt_easy, days_ago):
|
||||||
"""Should suggest +1-2 reps when below wk4 target."""
|
"""Helper to log a workout session with 3 sets."""
|
||||||
session, service, user, day, exercise, program = self._setup()
|
|
||||||
|
|
||||||
# Log a session where user did 8 reps (wk1 target)
|
|
||||||
ws = WorkoutSession(
|
ws = WorkoutSession(
|
||||||
user_id=user.id, workout_day_id=day.id,
|
user_id=user.id, workout_day_id=day.id,
|
||||||
date=date.today() - timedelta(days=7),
|
date=date.today() - timedelta(days=days_ago),
|
||||||
)
|
)
|
||||||
session.add(ws)
|
session.add(ws)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(ws)
|
session.refresh(ws)
|
||||||
|
|
||||||
for set_num in range(1, 4):
|
for set_num in range(1, 4):
|
||||||
session.add(WorkoutLog(
|
session.add(WorkoutLog(
|
||||||
session_id=ws.id, exercise_id=exercise.id,
|
session_id=ws.id, exercise_id=exercise.id,
|
||||||
set_number=set_num, reps_completed=8,
|
set_number=set_num, reps_completed=reps,
|
||||||
weight_used="30 lbs", felt_easy=False,
|
weight_used=weight, felt_easy=felt_easy,
|
||||||
))
|
))
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
def test_baseline_no_logs(self) -> None:
|
||||||
assert suggestion is not None
|
"""Should return 3x6 @ starting_weight when no logs exist."""
|
||||||
assert suggestion["suggested_reps"] >= 9 # +1-2 reps
|
|
||||||
assert suggestion["suggested_weight"] == "30 lbs" # same weight
|
|
||||||
assert suggestion["progression_type"] == "reps_increase"
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
def test_suggest_weight_increase(self) -> None:
|
|
||||||
"""Should suggest +5 lbs when at wk4 rep target and felt easy."""
|
|
||||||
session, service, user, day, exercise, program = self._setup()
|
|
||||||
|
|
||||||
# Log two sessions at max reps, all felt easy
|
|
||||||
for week_offset in [14, 7]:
|
|
||||||
ws = WorkoutSession(
|
|
||||||
user_id=user.id, workout_day_id=day.id,
|
|
||||||
date=date.today() - timedelta(days=week_offset),
|
|
||||||
)
|
|
||||||
session.add(ws)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(ws)
|
|
||||||
|
|
||||||
for set_num in range(1, 4):
|
|
||||||
session.add(WorkoutLog(
|
|
||||||
session_id=ws.id, exercise_id=exercise.id,
|
|
||||||
set_number=set_num, reps_completed=12,
|
|
||||||
weight_used="30 lbs", felt_easy=True,
|
|
||||||
))
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
|
||||||
assert suggestion is not None
|
|
||||||
assert suggestion["suggested_weight"] == "35 lbs" # +5 lbs
|
|
||||||
assert suggestion["progression_type"] == "weight_increase"
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
def test_suggest_deload(self) -> None:
|
|
||||||
"""Should suggest deload after 4 weeks of progression."""
|
|
||||||
session, service, user, day, exercise, program = self._setup()
|
|
||||||
|
|
||||||
# Log 4 weeks of sessions (simulate week 5 trigger)
|
|
||||||
for week in range(4):
|
|
||||||
ws = WorkoutSession(
|
|
||||||
user_id=user.id, workout_day_id=day.id,
|
|
||||||
date=date.today() - timedelta(days=(4 - week) * 7),
|
|
||||||
)
|
|
||||||
session.add(ws)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(ws)
|
|
||||||
|
|
||||||
for set_num in range(1, 4):
|
|
||||||
session.add(WorkoutLog(
|
|
||||||
session_id=ws.id, exercise_id=exercise.id,
|
|
||||||
set_number=set_num, reps_completed=12,
|
|
||||||
weight_used="40 lbs", felt_easy=False,
|
|
||||||
))
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
|
||||||
assert suggestion is not None
|
|
||||||
# After 4 consecutive weeks, week 5 should be deload
|
|
||||||
if suggestion["progression_type"] == "deload":
|
|
||||||
assert "32" in suggestion["suggested_weight"] # -20% of 40
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
def test_no_suggestion_without_logs(self) -> None:
|
|
||||||
"""Should return program defaults when no logs exist."""
|
|
||||||
session, service, user, day, exercise, program = self._setup()
|
session, service, user, day, exercise, program = self._setup()
|
||||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
assert suggestion is not None
|
assert suggestion["suggested_reps"] == 6
|
||||||
assert suggestion["suggested_reps"] == 8 # wk1 default
|
assert suggestion["suggested_weight"] == "30 lbs"
|
||||||
assert suggestion["suggested_weight"] == "30 lbs" # wk1 default
|
assert suggestion["suggested_sets"] == 3
|
||||||
|
assert suggestion["ladder_position"] == 0
|
||||||
assert suggestion["progression_type"] == "baseline"
|
assert suggestion["progression_type"] == "baseline"
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
def test_climb_when_felt_easy(self) -> None:
|
||||||
|
"""Should climb from 6 to 8 reps when all sets felt easy."""
|
||||||
|
session, service, user, day, exercise, program = self._setup()
|
||||||
|
self._log_session(session, user, day, exercise, reps=6, weight="30 lbs", felt_easy=True, days_ago=7)
|
||||||
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
|
assert suggestion["suggested_reps"] == 8
|
||||||
|
assert suggestion["suggested_weight"] == "30 lbs"
|
||||||
|
assert suggestion["progression_type"] == "climb"
|
||||||
|
assert suggestion["ladder_position"] == 1
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_hold_when_not_felt_easy(self) -> None:
|
||||||
|
"""Should hold at current reps when not all sets felt easy."""
|
||||||
|
session, service, user, day, exercise, program = self._setup()
|
||||||
|
self._log_session(session, user, day, exercise, reps=8, weight="30 lbs", felt_easy=False, days_ago=7)
|
||||||
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
|
assert suggestion["suggested_reps"] == 8
|
||||||
|
assert suggestion["suggested_weight"] == "30 lbs"
|
||||||
|
assert suggestion["progression_type"] == "hold"
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_weight_increase_at_12_felt_easy(self) -> None:
|
||||||
|
"""Should suggest +5 lbs when at 12 reps and all felt easy."""
|
||||||
|
session, service, user, day, exercise, program = self._setup()
|
||||||
|
self._log_session(session, user, day, exercise, reps=12, weight="30 lbs", felt_easy=True, days_ago=7)
|
||||||
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
|
assert suggestion["suggested_reps"] == 6
|
||||||
|
assert suggestion["suggested_weight"] == "35 lbs"
|
||||||
|
assert suggestion["progression_type"] == "weight_increase"
|
||||||
|
assert suggestion["ladder_position"] == 0
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_deload_after_4_struggling_sessions(self) -> None:
|
||||||
|
"""Should deload after 4 consecutive struggling sessions."""
|
||||||
|
session, service, user, day, exercise, program = self._setup()
|
||||||
|
for week in range(4):
|
||||||
|
self._log_session(session, user, day, exercise, reps=8, weight="40 lbs", felt_easy=False, days_ago=(4 - week) * 7)
|
||||||
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
|
assert suggestion["progression_type"] == "deload"
|
||||||
|
assert suggestion["suggested_reps"] == 6
|
||||||
|
assert "32" in suggestion["suggested_weight"] # 40 * 0.8 = 32
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_bodyweight_hold_at_top(self) -> None:
|
||||||
|
"""Bodyweight exercises should hold at 12 reps, no weight increase."""
|
||||||
|
session, service, user, day, exercise, program = self._setup(starting_weight="BW")
|
||||||
|
self._log_session(session, user, day, exercise, reps=12, weight="BW", felt_easy=True, days_ago=7)
|
||||||
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
|
assert suggestion["suggested_reps"] == 12
|
||||||
|
assert suggestion["suggested_weight"] == "BW"
|
||||||
|
assert suggestion["progression_type"] == "hold_at_top"
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_bodyweight_climb(self) -> None:
|
||||||
|
"""Bodyweight exercises should climb the ladder normally."""
|
||||||
|
session, service, user, day, exercise, program = self._setup(starting_weight="BW")
|
||||||
|
self._log_session(session, user, day, exercise, reps=8, weight="BW", felt_easy=True, days_ago=7)
|
||||||
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
|
assert suggestion["suggested_reps"] == 10
|
||||||
|
assert suggestion["progression_type"] == "climb"
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_no_program(self) -> None:
|
||||||
|
"""Should return no_program when no program exists."""
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
user = User(username="u", display_name="U")
|
||||||
|
exercise = Exercise(
|
||||||
|
name="Ex", muscle_group="Test",
|
||||||
|
workout_day="Push", sets=3, tempo="3-1-2", form_cues="...",
|
||||||
|
)
|
||||||
|
session.add_all([user, exercise])
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
session.refresh(exercise)
|
||||||
|
service = ProgressionService(session)
|
||||||
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
|
assert suggestion["progression_type"] == "no_program"
|
||||||
|
session.close()
|
||||||
|
|
||||||
def test_record_progression(self) -> None:
|
def test_record_progression(self) -> None:
|
||||||
"""record_progression should write to progress_log table."""
|
"""record_progression should write to progress_log table."""
|
||||||
session, service, user, day, exercise, program = self._setup()
|
session, service, user, day, exercise, program = self._setup()
|
||||||
@@ -152,10 +165,22 @@ class TestProgressionService:
|
|||||||
suggested_weight="30 lbs",
|
suggested_weight="30 lbs",
|
||||||
actual_reps=10,
|
actual_reps=10,
|
||||||
actual_weight="30 lbs",
|
actual_weight="30 lbs",
|
||||||
progression_type="reps_increase",
|
progression_type="climb",
|
||||||
)
|
)
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
logs = session.exec(select(ProgressLog)).all()
|
logs = session.exec(select(ProgressLog)).all()
|
||||||
assert len(logs) == 1
|
assert len(logs) == 1
|
||||||
assert logs[0].progression_applied == "reps_increase"
|
assert logs[0].progression_applied == "climb"
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_full_ladder_climb(self) -> None:
|
||||||
|
"""Should climb 6 -> 8 -> 10 -> 12 across sessions."""
|
||||||
|
session, service, user, day, exercise, program = self._setup()
|
||||||
|
expected_climbs = [(6, 8), (8, 10), (10, 12)]
|
||||||
|
for i, (current, expected_next) in enumerate(expected_climbs):
|
||||||
|
self._log_session(session, user, day, exercise, reps=current, weight="30 lbs", felt_easy=True, days_ago=(len(expected_climbs) - i) * 7)
|
||||||
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
|
# Latest session is 10 reps felt easy -> should suggest 12
|
||||||
|
assert suggestion["suggested_reps"] == 12
|
||||||
|
assert suggestion["progression_type"] == "climb"
|
||||||
session.close()
|
session.close()
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
"""Tests for schedule calendar routes."""
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchedule:
|
|
||||||
"""Tests for GET /schedule."""
|
|
||||||
|
|
||||||
def test_schedule_requires_auth(self, client: TestClient) -> None:
|
|
||||||
"""GET /schedule should require admin login."""
|
|
||||||
response = client.get("/schedule", follow_redirects=False)
|
|
||||||
assert response.status_code in (401, 302, 303)
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
"""Tests for the SeedService class."""
|
"""Tests for the SeedService class."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import bcrypt
|
|
||||||
from sqlmodel import SQLModel, Session, create_engine, select
|
from sqlmodel import SQLModel, Session, create_engine, select
|
||||||
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -53,20 +51,6 @@ class TestSeedService:
|
|||||||
assert len(warmups) == 6
|
assert len(warmups) == 6
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
def test_seed_admin_user(self) -> None:
|
|
||||||
"""seed_admin should create admin user with hashed password."""
|
|
||||||
session, service = self._setup()
|
|
||||||
with patch.dict("os.environ", {
|
|
||||||
"ADMIN_USERNAME": "admin",
|
|
||||||
"ADMIN_PASSWORD": "testpass",
|
|
||||||
}):
|
|
||||||
service.seed_admin()
|
|
||||||
admin = session.exec(select(User).where(User.is_admin == True)).first() # noqa: E712
|
|
||||||
assert admin is not None
|
|
||||||
assert admin.username == "admin"
|
|
||||||
assert bcrypt.checkpw(b"testpass", admin.password_hash.encode())
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
def test_seed_user_programs(self) -> None:
|
def test_seed_user_programs(self) -> None:
|
||||||
"""seed_user_programs should create user profiles and link exercises."""
|
"""seed_user_programs should create user profiles and link exercises."""
|
||||||
session, service = self._setup()
|
session, service = self._setup()
|
||||||
@@ -74,19 +58,15 @@ class TestSeedService:
|
|||||||
service.seed_user_programs()
|
service.seed_user_programs()
|
||||||
programs = session.exec(select(UserExerciseProgram)).all()
|
programs = session.exec(select(UserExerciseProgram)).all()
|
||||||
assert len(programs) > 0
|
assert len(programs) > 0
|
||||||
users = session.exec(select(User).where(User.is_admin == False)).all() # noqa: E712
|
users = session.exec(select(User)).all()
|
||||||
assert len(users) == 2 # Phillip and Daughter
|
assert len(users) == 2 # Phillip and Daughter
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
def test_seed_is_idempotent(self) -> None:
|
def test_seed_is_idempotent(self) -> None:
|
||||||
"""Running seed_all twice should not create duplicate records."""
|
"""Running seed_all twice should not create duplicate records."""
|
||||||
session, service = self._setup()
|
session, service = self._setup()
|
||||||
with patch.dict("os.environ", {
|
service.seed_all()
|
||||||
"ADMIN_USERNAME": "admin",
|
service.seed_all()
|
||||||
"ADMIN_PASSWORD": "testpass",
|
|
||||||
}):
|
|
||||||
service.seed_all()
|
|
||||||
service.seed_all()
|
|
||||||
users = session.exec(select(User)).all()
|
users = session.exec(select(User)).all()
|
||||||
exercises = session.exec(select(Exercise)).all()
|
exercises = session.exec(select(Exercise)).all()
|
||||||
# Should not be doubled
|
# Should not be doubled
|
||||||
|
|||||||
@@ -22,12 +22,10 @@ class TestUserService:
|
|||||||
session, service = self._setup()
|
session, service = self._setup()
|
||||||
user = service.create_user(
|
user = service.create_user(
|
||||||
username="phil",
|
username="phil",
|
||||||
password_hash="hashed",
|
|
||||||
display_name="Phillip",
|
display_name="Phillip",
|
||||||
height="6'0\"",
|
height="6'0\"",
|
||||||
weight="260 lbs",
|
weight="260 lbs",
|
||||||
goals="Muscle build",
|
goals="Muscle build",
|
||||||
is_admin=False,
|
|
||||||
)
|
)
|
||||||
assert user.id is not None
|
assert user.id is not None
|
||||||
assert user.username == "phil"
|
assert user.username == "phil"
|
||||||
@@ -37,7 +35,7 @@ class TestUserService:
|
|||||||
"""get_user_by_id should return the correct user."""
|
"""get_user_by_id should return the correct user."""
|
||||||
session, service = self._setup()
|
session, service = self._setup()
|
||||||
created = service.create_user(
|
created = service.create_user(
|
||||||
username="test", password_hash="h", display_name="Test"
|
username="test", display_name="Test"
|
||||||
)
|
)
|
||||||
found = service.get_user_by_id(created.id)
|
found = service.get_user_by_id(created.id)
|
||||||
assert found is not None
|
assert found is not None
|
||||||
@@ -47,10 +45,10 @@ class TestUserService:
|
|||||||
def test_get_user_by_username(self) -> None:
|
def test_get_user_by_username(self) -> None:
|
||||||
"""get_user_by_username should return the correct user."""
|
"""get_user_by_username should return the correct user."""
|
||||||
session, service = self._setup()
|
session, service = self._setup()
|
||||||
service.create_user(username="admin", password_hash="h", display_name="Admin")
|
service.create_user(username="phil", display_name="Phillip")
|
||||||
found = service.get_user_by_username("admin")
|
found = service.get_user_by_username("phil")
|
||||||
assert found is not None
|
assert found is not None
|
||||||
assert found.display_name == "Admin"
|
assert found.display_name == "Phillip"
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
def test_get_user_by_username_not_found(self) -> None:
|
def test_get_user_by_username_not_found(self) -> None:
|
||||||
@@ -63,26 +61,16 @@ class TestUserService:
|
|||||||
def test_list_users(self) -> None:
|
def test_list_users(self) -> None:
|
||||||
"""list_users should return all users."""
|
"""list_users should return all users."""
|
||||||
session, service = self._setup()
|
session, service = self._setup()
|
||||||
service.create_user(username="a", password_hash="h", display_name="A")
|
service.create_user(username="a", display_name="A")
|
||||||
service.create_user(username="b", password_hash="h", display_name="B")
|
service.create_user(username="b", display_name="B")
|
||||||
users = service.list_users()
|
users = service.list_users()
|
||||||
assert len(users) == 2
|
assert len(users) == 2
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
def test_list_non_admin_users(self) -> None:
|
|
||||||
"""list_users with exclude_admin=True should skip admin users."""
|
|
||||||
session, service = self._setup()
|
|
||||||
service.create_user(username="admin", password_hash="h", display_name="Admin", is_admin=True)
|
|
||||||
service.create_user(username="user", password_hash="h", display_name="User", is_admin=False)
|
|
||||||
users = service.list_users(exclude_admin=True)
|
|
||||||
assert len(users) == 1
|
|
||||||
assert users[0].username == "user"
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
def test_update_user(self) -> None:
|
def test_update_user(self) -> None:
|
||||||
"""update_user should modify the specified fields."""
|
"""update_user should modify the specified fields."""
|
||||||
session, service = self._setup()
|
session, service = self._setup()
|
||||||
user = service.create_user(username="u", password_hash="h", display_name="Old")
|
user = service.create_user(username="u", display_name="Old")
|
||||||
updated = service.update_user(user.id, display_name="New", weight="250 lbs")
|
updated = service.update_user(user.id, display_name="New", weight="250 lbs")
|
||||||
assert updated.display_name == "New"
|
assert updated.display_name == "New"
|
||||||
assert updated.weight == "250 lbs"
|
assert updated.weight == "250 lbs"
|
||||||
|
|||||||
@@ -2,16 +2,26 @@
|
|||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.conftest import set_profile_cookie
|
||||||
|
|
||||||
|
|
||||||
class TestWorkoutDayViewer:
|
class TestWorkoutDayViewer:
|
||||||
"""Tests for GET /workouts/<day_name>."""
|
"""Tests for GET /workouts/<day_name>."""
|
||||||
|
|
||||||
def test_workout_day_requires_auth(self, client: TestClient) -> None:
|
def test_workout_day_requires_profile(self, client: TestClient) -> None:
|
||||||
"""GET /workouts/push should require admin login."""
|
"""GET /workouts/push should redirect to / without profile cookie."""
|
||||||
response = client.get("/workouts/push", follow_redirects=False)
|
response = client.get("/workouts/push", follow_redirects=False)
|
||||||
assert response.status_code in (401, 302, 303)
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|
||||||
def test_workout_days_list_requires_auth(self, client: TestClient) -> None:
|
def test_workout_days_list_requires_profile(self, client: TestClient) -> None:
|
||||||
"""GET /workouts should require admin login."""
|
"""GET /workouts should redirect to / without profile cookie."""
|
||||||
response = client.get("/workouts", follow_redirects=False)
|
response = client.get("/workouts", follow_redirects=False)
|
||||||
assert response.status_code in (401, 302, 303)
|
assert response.status_code == 302
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
|
||||||
|
def test_workout_days_list_with_profile(self, client: TestClient) -> None:
|
||||||
|
"""GET /workouts should succeed with a valid profile cookie."""
|
||||||
|
set_profile_cookie(client, 1)
|
||||||
|
response = client.get("/workouts")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class TestWorkoutSessionService:
|
|||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
session = Session(engine)
|
session = Session(engine)
|
||||||
|
|
||||||
user = User(username="phil", password_hash="h", display_name="Phillip")
|
user = User(username="phil", display_name="Phillip")
|
||||||
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
||||||
session.add_all([user, day])
|
session.add_all([user, day])
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user