27 Commits

Author SHA1 Message Date
059ba8f778 fix: remove service_healthy condition unsupported by podman 4.3.1
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:01:08 -05:00
4e6930c207 fix: reorder nav menu to place Dashboard after Home
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 12s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:57:43 -05:00
cea1b4e80e feat: add Caddy reverse proxy in front of app
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11s
Caddy listens on port 80 and proxies to the app on 8000 internally.
App port is no longer exposed to the host directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:49:01 -05:00
bae0bc9dee feat: add missing import service and templates
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 12s
These files were untracked and missing from prior commits, causing
ModuleNotFoundError on fresh deploys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:46:37 -05:00
df8d5c65fb feat: enhance dashboard with PRs, adherence, activity, progression chart, and muscle heatmap
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 13s
Add 3 new stat cards (Last Workout, Personal Records, Adherence Rate),
recent activity table, progression timeline chart, and muscle group
recency heatmap to the dashboard. Remove Total Volume card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:44:21 -05:00
c5a7728818 Merge origin/master: integrate auto-populate suggestions and set renumbering
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 36s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:43:14 -05:00
ebecfd0b58 feat: add CSV export of workout history to dashboard
Add date range picker and download button to the dashboard page,
backed by GET /dashboard/export endpoint that returns a StreamingResponse
CSV file. Completes Phase 4 (final V2 improvement).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:22:50 -05:00
0389aef56e Merge branch 'feature/rep-ladder-progression' 2026-03-13 13:57:04 -05:00
52e48f8ed4 feat: replace wk1/wk4 targets with 6→8→10→12 rep ladder progression
Simplifies the progression model to a universal rep ladder: every exercise
follows 6→8→10→12 reps at current weight, then +5 lbs and reset to 6.
Replaces per-user wk1/wk4 rep and weight targets with a single
starting_weight field.

- Add Alembic migration to drop wk1_reps/wk4_reps/wk1_weight/wk4_weight,
  add starting_weight (migrated from wk1_weight)
- Run Alembic migrations on app startup instead of create_all, with
  auto-detection and stamping for legacy databases
- Include alembic/ and alembic.ini in Docker image
- Rewrite progression_service.get_suggestion() with ladder logic:
  climb, hold, weight_increase, hold_at_top, deload
- Replace wk1/wk4 grid in exercise cards with rep ladder progress bar
- Add color-coded progression badges by type
- Change weight log input from text to number with pre-filled suggestion
- Normalize weight input in routes (0→BW, bare number→N lbs)
- Remove schedule page (route, template, nav link, tests)
- Simplify user_programs.yaml from 4 fields to 1 per exercise
- Update all tests for new schema and progression logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:57:02 -05:00
69b3357800 updating roadmap so phase 2 is complete 2026-03-13 13:07:37 -05:00
4b117c6fa7 feat: add smart "Workout Now" recommendation to workout picker
Auto-recommends the next workout in the 4-day cycle based on the
user's last completed session (one with logged sets). Redesigns
the /workouts page with highlighted recommendation card and
renames the back button to "Change Workout".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:05:39 -05:00
931e452205 docs: mark roadmap item 1 (remove auth) as complete
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:52:48 -05:00
5b26f36f5d fix: resolve stash conflict and update roadmap to v2
Resolved merge conflict in exercise_card.html (kept suggested
reps/weight pre-population logic) and updated roadmap.md with
the new v2 improvement plan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:50:34 -05:00
758034b25a Merge branch 'feature/remove-auth' into master 2026-03-13 12:40:58 -05:00
576d3bbb68 feat: replace admin auth with cookie-based profile picker
Remove all authentication (login, sessions, bcrypt, itsdangerous) since
the app runs on a private homelab LAN. Replace with a profile picker
landing page and cookie-based profile selection (1-year expiry).

- Add Alembic migration to drop password_hash/is_admin columns
- Delete auth service, auth routes, login template, and auth tests
- Rewrite app/utils/auth.py with NoProfileSelectedError and
  require_active_profile dependency
- Add profile creation flow (GET/POST /profiles/create)
- Rewrite home page as profile picker with card layout
- Update all route files to use profile dependency instead of admin auth
- Remove bcrypt and itsdangerous from requirements
- Remove admin_username/admin_password from config
- Update all tests for new profile-based access model

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:40:54 -05:00
91b3d24147 Merge pull request 'fix: renumber sets after delete and use last-logged values for prefill' (#2) from feature/auto-populate-suggestions into master
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 12s
Reviewed-on: #2
2026-02-24 21:56:32 +00:00
2208f0492b fix: renumber sets after delete and use last-logged values for prefill
Two bugs fixed:
- Deleting a set left gaps in set numbering (1, 3, 3). Now renumbers
  remaining sets sequentially after deletion.
- Logging set 1 caused the prefill to recalculate via the progression
  engine, shifting suggested reps mid-session. Now prefills from the
  last logged set's actual values; progression suggestion is only used
  for the first set of a session.
2026-02-24 15:55:27 -06:00
60acdbefdb Merge pull request 'feature/auto-populate-suggestions' (#1) from feature/auto-populate-suggestions into master
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11s
Reviewed-on: #1
2026-02-24 21:49:05 +00:00
7b535bef6e fix(tests): align auth tests with NotAuthenticatedError and 302 redirect
The auth dependency raises NotAuthenticatedError (not HTTPException),
and the exception handler returns a 302 redirect. Updated the unit test
to expect NotAuthenticatedError, and all route auth tests to accept 302
alongside 401/303.
2026-02-24 15:47:36 -06:00
272563060c feat: auto-populate suggested reps and weight in log form
Pre-fill the reps and weight inputs with progression engine suggestions
so users can log sets without manually retyping values each time.
Suggestions flow through the template chain on initial page load and
on all HTMX partial responses (log, edit, delete).
2026-02-24 15:46:04 -06:00
3dc0171639 updating dockerfiles for deployment vs local dev
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 10s
2026-02-24 15:37:21 -06:00
312b14e57b updating dockerfiles for deployment vs local dev 2026-02-24 15:37:16 -06:00
d8b52cf907 fix(ci): use REGISTRY_TOKEN secret for container registry auth
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 23s
The automatic GITHUB_TOKEN lacks package write permissions. Switch to
a manually configured PAT stored as REGISTRY_TOKEN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:47:09 -06:00
b18146e96c fix(ci): use act-latest container for Docker and Node.js support
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 7s
Replace manual Node.js install with catthehacker/ubuntu:act-latest
container which includes Node.js, Docker, and all build dependencies
needed for Gitea Actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:42:30 -06:00
ee45513f30 fix(ci): install Node.js 20 for actions/checkout@v4 compatibility
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 9m38s
The default apt nodejs package is too old to support static class blocks
required by checkout@v4. Use NodeSource to install Node.js 20.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:32:39 -06:00
d90c9faf23 Merge branch 'feat/package-build-workflow'
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 35s
2026-02-24 14:31:28 -06:00
093f7aa55e ci: add Gitea Actions workflow for Docker build and registry push
Adds a CI pipeline that builds the Docker image and pushes it to the
Gitea container registry on every push to master. Includes .dockerignore
to keep the build context lean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:30:19 -06:00
76 changed files with 2150 additions and 1543 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.git
.gitignore
.env
.env.example
data/
docs/
tests/
alembic/__pycache__
*.md
*.lock
.claude/
.gitea/
__pycache__
*.pyc
docker-compose.yaml
docker-compose.dev.yaml

View File

@@ -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

9
.env.production Normal file
View File

@@ -0,0 +1,9 @@
# SneakySwole Production Environment
# Copy to .env on your production server and fill in real values.
APP_ENV=production
APP_HOST=0.0.0.0
APP_PORT=8000
APP_LOG_LEVEL=warning
DATABASE_URL=sqlite:///data/sneakyswole.db

View File

@@ -0,0 +1,46 @@
name: Build and Push Docker Image
on:
push:
branches:
- master
workflow_dispatch: {}
jobs:
build-and-push:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.sneakygeek.net
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: git.sneakygeek.net/sneakygeek/sneakyswole
tags: |
type=raw,value=latest
type=sha,prefix=sha-,format=short
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=git.sneakygeek.net/sneakygeek/sneakyswole:buildcache
cache-to: type=registry,ref=git.sneakygeek.net/sneakygeek/sneakyswole:buildcache,mode=max

3
Caddyfile Normal file
View File

@@ -0,0 +1,3 @@
:80 {
reverse_proxy app:8000
}

View File

@@ -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

View 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'))

View 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')

View File

@@ -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",
) )

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
}) })

View File

@@ -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,

View File

@@ -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,
}) })

View File

@@ -14,53 +14,79 @@ 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.log_service import LogService from app.services.log_service import LogService
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(
logs: list,
session: Session,
profile_id: int,
exercise_id: int,
) -> tuple:
"""Get pre-fill values for the next set form.
If sets have already been logged this session, use the last logged
set's values (users typically repeat the same reps/weight across sets).
Otherwise, use the progression engine's suggestion.
Returns:
(suggested_reps, suggested_weight) tuple.
"""
if logs:
last = logs[-1]
return last.reps_completed, last.weight_used
progression = ProgressionService(session)
suggestion = progression.get_suggestion(profile_id, exercise_id)
return suggestion.get("suggested_reps"), suggestion.get("suggested_weight")
@router.post("", response_class=HTMLResponse) @router.post("", response_class=HTMLResponse)
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(),
) )
@@ -79,6 +105,9 @@ async def log_set(
# Return updated logs for this exercise # Return updated logs for this exercise
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(
logs, session, profile.id, exercise_id,
)
templates = request.app.state.templates templates = request.app.state.templates
return templates.TemplateResponse("partials/log_entry.html", { return templates.TemplateResponse("partials/log_entry.html", {
@@ -88,6 +117,8 @@ async def log_set(
"workout_day_id": workout_day_id, "workout_day_id": workout_day_id,
"next_set": next_set, "next_set": next_set,
"session_id": ws.id, "session_id": ws.id,
"suggested_reps": suggested_reps,
"suggested_weight": suggested_weight,
}) })
@@ -96,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"),
) )
@@ -124,6 +145,13 @@ async def edit_log(
logs = log_service.list_logs_for_exercise(log.session_id, log.exercise_id) logs = log_service.list_logs_for_exercise(log.session_id, log.exercise_id)
next_set = len(logs) + 1 next_set = len(logs) + 1
active_profile_id = get_active_profile_id(request)
suggested_reps, suggested_weight = None, None
if active_profile_id:
suggested_reps, suggested_weight = _get_prefill_values(
logs, session, active_profile_id, log.exercise_id,
)
templates = request.app.state.templates templates = request.app.state.templates
return templates.TemplateResponse("partials/log_entry.html", { return templates.TemplateResponse("partials/log_entry.html", {
"request": request, "request": request,
@@ -132,6 +160,8 @@ async def edit_log(
"workout_day_id": 0, "workout_day_id": 0,
"next_set": next_set, "next_set": next_set,
"session_id": log.session_id, "session_id": log.session_id,
"suggested_reps": suggested_reps,
"suggested_weight": suggested_weight,
}) })
@@ -140,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)
@@ -164,6 +184,13 @@ async def delete_log(
logs = log_service.list_logs_for_exercise(session_id, exercise_id) logs = log_service.list_logs_for_exercise(session_id, exercise_id)
next_set = len(logs) + 1 next_set = len(logs) + 1
active_profile_id = get_active_profile_id(request)
suggested_reps, suggested_weight = None, None
if active_profile_id:
suggested_reps, suggested_weight = _get_prefill_values(
logs, session, active_profile_id, exercise_id,
)
templates = request.app.state.templates templates = request.app.state.templates
return templates.TemplateResponse("partials/log_entry.html", { return templates.TemplateResponse("partials/log_entry.html", {
"request": request, "request": request,
@@ -172,6 +199,8 @@ async def delete_log(
"workout_day_id": 0, "workout_day_id": 0,
"next_set": next_set, "next_set": next_set,
"session_id": session_id, "session_id": session_id,
"suggested_reps": suggested_reps,
"suggested_weight": suggested_weight,
}) })
return HTMLResponse("") return HTMLResponse("")

View File

@@ -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,
})

View File

@@ -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)

View File

@@ -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,
})

View File

@@ -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,
}) })

View File

@@ -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}

View File

@@ -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

View 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

View 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

View File

@@ -149,7 +149,8 @@ class LogService:
def delete_log(self, log_id: int) -> None: def delete_log(self, log_id: int) -> None:
"""Delete a log entry. """Delete a log entry.
Removes the log and cleans up the parent session if no logs remain. Removes the log, renumbers remaining sets, and cleans up the
parent session if no logs remain.
Args: Args:
log_id: The log entry ID. log_id: The log entry ID.
@@ -162,15 +163,32 @@ class LogService:
raise ValueError(f"WorkoutLog with id {log_id} not found") raise ValueError(f"WorkoutLog with id {log_id} not found")
session_id = log.session_id session_id = log.session_id
exercise_id = log.exercise_id
self._session.delete(log) self._session.delete(log)
self._session.commit() self._session.commit()
logger.info("log_deleted", log_id=log_id) logger.info("log_deleted", log_id=log_id)
# Clean up orphaned session if no logs remain # Renumber remaining sets so they stay sequential (1, 2, 3...)
remaining = self._session.exec( remaining = self._session.exec(
select(WorkoutLog)
.where(
WorkoutLog.session_id == session_id,
WorkoutLog.exercise_id == exercise_id,
)
.order_by(WorkoutLog.set_number)
).all()
for i, remaining_log in enumerate(remaining, start=1):
if remaining_log.set_number != i:
remaining_log.set_number = i
self._session.add(remaining_log)
if remaining:
self._session.commit()
# Clean up orphaned session if no logs remain for ANY exercise
any_remaining = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == session_id) select(WorkoutLog).where(WorkoutLog.session_id == session_id)
).first() ).first()
if remaining is None: if any_remaining is None:
ws = self._session.get(WorkoutSession, session_id) ws = self._session.get(WorkoutSession, session_id)
if ws: if ws:
self._session.delete(ws) self._session.delete(ws)

View File

@@ -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(

View File

@@ -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")

View File

@@ -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:

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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 %}

View File

@@ -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 %} &middot; {% 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 %}

View File

@@ -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 %}

View 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&quot;">
</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="/">&larr; Back to profiles</a></p>
{% endblock %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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] %}
@@ -32,6 +42,17 @@
<!-- Inline logging (Phase 4) --> <!-- Inline logging (Phase 4) -->
{% if active_profile %} {% if active_profile %}
<div id="logs-exercise-{{ exercise.id }}"> <div id="logs-exercise-{{ exercise.id }}">
{% if 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] %}
{% 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 %}

View 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>
&nbsp;
<button type="submit">Download CSV</button>
</label>
</div>
</form>
</article>

View 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>
&nbsp;
<button type="submit">Import</button>
</label>
</div>
</form>
<div id="import-results"></div>
</article>

View 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 %}

View File

@@ -11,9 +11,15 @@
<small style="white-space:nowrap; opacity:0.7;">Set {{ next_set|default(1) }}</small> <small style="white-space:nowrap; opacity:0.7;">Set {{ next_set|default(1) }}</small>
<input type="number" name="reps" placeholder="Reps" <input type="number" name="reps" placeholder="Reps"
min="0" max="100" required min="0" max="100" required
{% 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 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;">

View 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>

View File

@@ -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 %}

View File

@@ -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;">

View 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>

View 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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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"

29
docker-compose.dev.yaml Normal file
View File

@@ -0,0 +1,29 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: sneakyswole-dev
ports:
- "${APP_PORT:-8000}:8000"
volumes:
- sneakyswole-data:/app/data
- ./app:/app/app
- ./config:/app/config
env_file:
- .env
environment:
- APP_ENV=development
- APP_LOG_LEVEL=debug
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
sneakyswole-data:
driver: local

View File

@@ -1,15 +1,29 @@
services: services:
app: caddy:
build: image: caddy:2-alpine
context: . container_name: sneakyswole-proxy
dockerfile: Dockerfile
container_name: sneakyswole
ports: ports:
- "8000:8000" - "80:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
depends_on:
- app
networks:
- sneakyswole
restart: unless-stopped
app:
image: git.sneakygeek.net/sneakygeek/sneakyswole:latest
container_name: sneakyswole
expose:
- "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')"]
@@ -18,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

View File

@@ -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)

View File

@@ -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

2
run_dev_docker.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
docker compose -f docker-compose.dev.yaml up --build "$@"

View File

@@ -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))

View File

@@ -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",

View File

@@ -1,56 +0,0 @@
"""Tests for the auth dependency (require_admin)."""
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from app.utils.auth import 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 redirect to /login (303) when no session cookie is present."""
request = MagicMock()
request.cookies = {}
with pytest.raises(HTTPException) as exc_info:
get_current_admin_user(request=request, session=MagicMock())
assert exc_info.value.status_code == 303
def test_redirects_when_invalid_token(self) -> None:
"""Should redirect to /login (303) 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(HTTPException) as exc_info:
get_current_admin_user(request=request, session=mock_session)
assert exc_info.value.status_code == 303
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

View File

@@ -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", "")

View File

@@ -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()

View File

@@ -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

View File

@@ -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, 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, 303) assert response.status_code == 302
assert response.headers["location"] == "/"

View File

@@ -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, 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, 303) assert response.status_code == 302
assert response.headers["location"] == "/"

View File

@@ -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, 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, 303) assert response.status_code == 302
assert response.headers["location"] == "/"

View File

@@ -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",

View File

@@ -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, 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, 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, 303) assert response.status_code == 302
assert response.headers["location"] == "/"

View File

@@ -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
View 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"] == "/"

View File

@@ -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, 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, 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

View File

@@ -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()

View File

@@ -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, 303)

View File

@@ -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

View File

@@ -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"

View File

@@ -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, 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, 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

View File

@@ -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()