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