Compare commits

..

16 Commits

Author SHA1 Message Date
1f47103480 Merge branch 'feat/phase2-data-layer' 2026-02-24 10:15:10 -06:00
7c07bcee25 feat: wire database init and seeding into app startup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:14:32 -06:00
6f9e923846 feat: add SeedService for YAML-based database seeding
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:09:05 -06:00
42f6667b23 feat: add ExerciseService with filtering and warmup support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:07:33 -06:00
afb2cdf308 feat: add UserService with CRUD operations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:04:55 -06:00
e6ee17d1ff feat: add Alembic migrations with initial 8-table schema
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:52:31 -06:00
3bf1e13adc feat: define all 8 SQLModel tables
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:35:13 -06:00
45b93a2988 feat: add database engine and session management
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:32:26 -06:00
f9ff42c665 Merge branch 'feat/phase1-scaffold' 2026-02-24 09:16:03 -06:00
835ab197eb feat: add Dockerfile and docker-compose for containerized deployment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:15:01 -06:00
df7e86f2ed feat: add base template with Pico CSS dark theme and home page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:13:28 -06:00
b3b34222c8 feat: add FastAPI app factory and health check endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:12:31 -06:00
fb4b948681 feat: add structlog logging configuration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:05:14 -06:00
a211f9b6bd feat: add typed configuration loader with env support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:04:41 -06:00
2a9891ce69 feat: create project directory structure and env template
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 08:55:04 -06:00
4afc7c35a6 feat: add project metadata and pinned dependencies
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 08:54:46 -06:00
48 changed files with 2298 additions and 0 deletions

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# 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
APP_PORT=8000
APP_LOG_LEVEL=info
# Database
DATABASE_URL=sqlite:///data/sneakyswole.db

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# -- Build stage --
FROM python:3.12-slim AS base
# Prevent Python from writing .pyc files and enable unbuffered output
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Install dependencies first (layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ ./app/
COPY config/ ./config/
# Create data directory for SQLite
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

149
alembic.ini Normal file
View File

@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = sqlite:///data/sneakyswole.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

57
alembic/env.py Normal file
View File

@@ -0,0 +1,57 @@
"""Alembic environment configuration.
Imports all SQLModel models so autogenerate detects schema changes.
"""
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from sqlmodel import SQLModel
# Import all models so SQLModel.metadata knows about them
from app.models import ( # noqa: F401
User, Exercise, Warmup, WorkoutDay,
UserExerciseProgram, WorkoutSession, WorkoutLog, ProgressLog,
)
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = SQLModel.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode (SQL script generation)."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode (direct DB connection)."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

28
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,155 @@
"""initial schema - 8 tables
Revision ID: 1855836abf6c
Revises:
Create Date: 2026-02-24 09:35:47.297957
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '1855836abf6c'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('exercises',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('muscle_group', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('workout_day', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('sets', sa.Integer(), nullable=False),
sa.Column('tempo', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('form_cues', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_exercises_name'), 'exercises', ['name'], unique=False)
op.create_index(op.f('ix_exercises_workout_day'), 'exercises', ['workout_day'], unique=False)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('height', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('weight', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('goals', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('is_admin', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('warmups',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('reps', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('form_cues', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_warmups_name'), 'warmups', ['name'], unique=False)
op.create_table('workout_days',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('day_number', sa.Integer(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('day_number')
)
op.create_index(op.f('ix_workout_days_name'), 'workout_days', ['name'], unique=True)
op.create_table('progress_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('exercise_id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('suggested_reps', sa.Integer(), nullable=True),
sa.Column('suggested_weight', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('actual_reps', sa.Integer(), nullable=True),
sa.Column('actual_weight', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('progression_applied', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['exercise_id'], ['exercises.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_progress_log_user_id'), 'progress_log', ['user_id'], unique=False)
op.create_table('user_exercise_programs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('exercise_id', sa.Integer(), nullable=False),
sa.Column('wk1_reps', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('wk4_reps', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('wk1_weight', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('wk4_weight', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['exercise_id'], ['exercises.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_exercise_programs_exercise_id'), 'user_exercise_programs', ['exercise_id'], unique=False)
op.create_index(op.f('ix_user_exercise_programs_user_id'), 'user_exercise_programs', ['user_id'], unique=False)
op.create_table('workout_sessions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('workout_day_id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['workout_day_id'], ['workout_days.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_workout_sessions_user_id'), 'workout_sessions', ['user_id'], unique=False)
op.create_table('workout_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('session_id', sa.Integer(), nullable=False),
sa.Column('exercise_id', sa.Integer(), nullable=False),
sa.Column('set_number', sa.Integer(), nullable=False),
sa.Column('reps_completed', sa.Integer(), nullable=False),
sa.Column('weight_used', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('felt_easy', sa.Boolean(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['exercise_id'], ['exercises.id'], ),
sa.ForeignKeyConstraint(['session_id'], ['workout_sessions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_workout_logs_session_id'), 'workout_logs', ['session_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_workout_logs_session_id'), table_name='workout_logs')
op.drop_table('workout_logs')
op.drop_index(op.f('ix_workout_sessions_user_id'), table_name='workout_sessions')
op.drop_table('workout_sessions')
op.drop_index(op.f('ix_user_exercise_programs_user_id'), table_name='user_exercise_programs')
op.drop_index(op.f('ix_user_exercise_programs_exercise_id'), table_name='user_exercise_programs')
op.drop_table('user_exercise_programs')
op.drop_index(op.f('ix_progress_log_user_id'), table_name='progress_log')
op.drop_table('progress_log')
op.drop_index(op.f('ix_workout_days_name'), table_name='workout_days')
op.drop_table('workout_days')
op.drop_index(op.f('ix_warmups_name'), table_name='warmups')
op.drop_table('warmups')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_exercises_workout_day'), table_name='exercises')
op.drop_index(op.f('ix_exercises_name'), table_name='exercises')
op.drop_table('exercises')
# ### end Alembic commands ###

0
app/__init__.py Normal file
View File

53
app/config.py Normal file
View File

@@ -0,0 +1,53 @@
"""Application configuration loader.
Reads environment variables (with .env file support) and provides
a typed, validated Settings object used throughout the application.
"""
from functools import lru_cache
from pathlib import Path
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict
# Load .env file from project root
_env_path = Path(__file__).resolve().parent.parent / ".env"
load_dotenv(_env_path)
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.
app_log_level: Minimum log level for structlog output.
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
app_log_level: str = "info"
database_url: str = "sqlite:///data/sneakyswole.db"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Return a cached Settings instance (singleton pattern).
Returns:
The application Settings object.
"""
return Settings()

60
app/database.py Normal file
View File

@@ -0,0 +1,60 @@
"""Database engine and session management.
Provides a SQLModel engine factory and a session dependency
for use with FastAPI's dependency injection.
"""
from pathlib import Path
from typing import Generator
import structlog
from sqlmodel import Session, create_engine
logger = structlog.get_logger(__name__)
def get_engine(database_url: str):
"""Create a SQLAlchemy engine for the given database URL.
For SQLite, ensures the parent directory exists and sets
appropriate connection arguments.
Args:
database_url: SQLAlchemy-compatible connection string.
Returns:
A configured SQLAlchemy Engine.
"""
connect_args = {}
if database_url.startswith("sqlite"):
# Ensure the data directory exists
db_path = database_url.replace("sqlite:///", "")
if db_path and db_path != ":memory:":
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
connect_args = {"check_same_thread": False}
engine = create_engine(
database_url,
connect_args=connect_args,
echo=False,
)
logger.info("database_engine_created", url=database_url)
return engine
def get_db_session(engine) -> Generator[Session, None, None]:
"""Yield a SQLModel session for dependency injection.
Usage in FastAPI routes:
@router.get("/example")
def example(session: Session = Depends(get_db_session)):
...
Args:
engine: The SQLAlchemy engine to bind the session to.
Yields:
A SQLModel Session that auto-closes after use.
"""
with Session(engine) as session:
yield session

48
app/logging_config.py Normal file
View File

@@ -0,0 +1,48 @@
"""Structured logging configuration using structlog.
Call setup_logging() once at application startup to configure
structlog with human-readable (dev) or JSON (prod) output.
"""
import logging
import sys
import structlog
def setup_logging(log_level: str = "info") -> None:
"""Configure structlog for the application.
In development, output is colorized and human-readable.
In production, output is JSON for structured log aggregation.
Args:
log_level: Minimum log level as a string (e.g., 'info', 'debug').
"""
numeric_level = getattr(logging, log_level.upper(), logging.INFO)
# Configure standard library logging (captured by structlog)
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=numeric_level,
)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.dev.ConsoleRenderer(),
],
wrapper_class=structlog.stdlib.BoundLogger,
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)

80
app/main.py Normal file
View File

@@ -0,0 +1,80 @@
"""FastAPI application factory for SneakySwole.
Creates and configures the FastAPI app with routes, templates,
static files, and structured logging.
"""
from pathlib import Path
import structlog
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlmodel import SQLModel, Session
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.health import router as health_router
from app.routes.pages import router as pages_router
from app.services.seed_service import SeedService
logger = structlog.get_logger(__name__)
# Template and static file directories
_BASE_DIR = Path(__file__).resolve().parent
TEMPLATES_DIR = _BASE_DIR / "templates"
STATIC_DIR = _BASE_DIR / "static"
def create_app() -> FastAPI:
"""Create and configure the FastAPI application.
Returns:
A fully configured FastAPI application instance.
"""
settings = get_settings()
setup_logging(log_level=settings.app_log_level)
app = FastAPI(
title="SneakySwole",
description="Open-source workout tracking and programming",
version="0.1.0",
)
# Mount static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# Jinja2 templates (available to routes via request.state or dependency)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
app.state.templates = templates
# Register route modules
app.include_router(health_router)
app.include_router(pages_router)
# Database setup
engine = get_engine(settings.database_url)
SQLModel.metadata.create_all(engine)
app.state.engine = engine
# DB session dependency for routes
def _get_session():
yield from get_db_session(engine)
app.dependency_overrides[get_db_session] = _get_session
# Seed database on startup
@app.on_event("startup")
async def on_startup():
with Session(engine) as session:
seed_service = SeedService(session)
seed_service.seed_all()
logger.info("app_started", environment=settings.app_env)
return app
# Uvicorn entry point
app = create_app()

24
app/models/__init__.py Normal file
View File

@@ -0,0 +1,24 @@
"""SQLModel model definitions for SneakySwole.
All 8 tables are defined here and re-exported for convenient imports.
"""
from app.models.user import User
from app.models.exercise import Exercise
from app.models.warmup import Warmup
from app.models.workout_day import WorkoutDay
from app.models.user_exercise_program import UserExerciseProgram
from app.models.workout_session import WorkoutSession
from app.models.workout_log import WorkoutLog
from app.models.progress_log import ProgressLog
__all__ = [
"User",
"Exercise",
"Warmup",
"WorkoutDay",
"UserExerciseProgram",
"WorkoutSession",
"WorkoutLog",
"ProgressLog",
]

35
app/models/exercise.py Normal file
View File

@@ -0,0 +1,35 @@
"""Exercise model for the exercise library catalog.
Each exercise belongs to a workout day and includes form cues.
"""
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class Exercise(SQLModel, table=True):
"""An exercise in the workout library.
Attributes:
id: Primary key, auto-incremented.
name: Exercise name (e.g., "DB Chest Press (Floor)").
muscle_group: Target muscle group (e.g., "Chest", "Shoulders").
workout_day: Which day this exercise belongs to (Push/Pull/Lower/Full Body).
sets: Default number of sets.
tempo: Tempo notation (e.g., "3-1-2" = 3s eccentric, 1s pause, 2s concentric).
form_cues: Detailed form instructions.
created_at: Timestamp when the record was created.
"""
__tablename__ = "exercises"
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
muscle_group: str = Field(default="")
workout_day: str = Field(index=True)
sets: int = Field(default=3)
tempo: str = Field(default="")
form_cues: str = Field(default="")
created_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -0,0 +1,39 @@
"""ProgressLog model for tracking progression suggestions and outcomes.
Records what the progression engine suggested vs what the user actually did.
"""
import datetime as dt
from typing import Optional
from sqlmodel import Field, SQLModel
class ProgressLog(SQLModel, table=True):
"""A progression tracking entry for a specific exercise.
Attributes:
id: Primary key, auto-incremented.
user_id: FK to users table.
exercise_id: FK to exercises table.
date: The date this progression entry applies to.
suggested_reps: What the engine recommended.
suggested_weight: What the engine recommended.
actual_reps: What the user actually did.
actual_weight: What the user actually used.
progression_applied: Type of progression (e.g., "reps_increase", "weight_increase", "deload").
created_at: Timestamp when the record was created.
"""
__tablename__ = "progress_log"
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")
date: dt.date = Field(default_factory=dt.date.today)
suggested_reps: Optional[int] = Field(default=None)
suggested_weight: Optional[str] = Field(default=None)
actual_reps: Optional[int] = Field(default=None)
actual_weight: Optional[str] = Field(default=None)
progression_applied: Optional[str] = Field(default=None)
created_at: dt.datetime = Field(default_factory=dt.datetime.utcnow)

39
app/models/user.py Normal file
View File

@@ -0,0 +1,39 @@
"""User model for profile management.
Stores admin and regular user profiles with physical stats and goals.
"""
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class User(SQLModel, table=True):
"""A user profile in the system.
Attributes:
id: Primary key, auto-incremented.
username: Unique login identifier.
password_hash: bcrypt-hashed password (admin only initially).
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.
"""
__tablename__ = "users"
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)

View File

@@ -0,0 +1,37 @@
"""UserExerciseProgram model for per-user exercise programming.
Links a user to an exercise with week 1 and week 4 rep/weight targets.
"""
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class UserExerciseProgram(SQLModel, table=True):
"""Per-user programming for a specific exercise.
Attributes:
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.
created_at: Timestamp when the record was created.
updated_at: Timestamp of the last update.
"""
__tablename__ = "user_exercise_programs"
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="")
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)

33
app/models/warmup.py Normal file
View File

@@ -0,0 +1,33 @@
"""Warmup model for the standardized warmup routine.
Warmups are displayed before every workout in a fixed order.
"""
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class Warmup(SQLModel, table=True):
"""A warmup exercise in the standardized routine.
Attributes:
id: Primary key, auto-incremented.
name: Warmup name (e.g., "Cat / Cow").
type: Category (e.g., "Thoracic Mob", "Hip Mobility").
reps: Rep scheme as a string (e.g., "8 reps", "8 each side").
form_cues: Detailed form instructions.
sort_order: Display order in the warmup sequence.
created_at: Timestamp when the record was created.
"""
__tablename__ = "warmups"
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
type: str = Field(default="")
reps: str = Field(default="")
form_cues: str = Field(default="")
sort_order: int = Field(default=0)
created_at: datetime = Field(default_factory=datetime.utcnow)

26
app/models/workout_day.py Normal file
View File

@@ -0,0 +1,26 @@
"""WorkoutDay model for the 4-day training split.
Defines the named workout days and their order.
"""
from typing import Optional
from sqlmodel import Field, SQLModel
class WorkoutDay(SQLModel, table=True):
"""A named workout day in the training program.
Attributes:
id: Primary key, auto-incremented.
name: Day name (Push, Pull, Lower, Full Body).
day_number: Order in the weekly rotation (1-4).
description: Brief description of the day's focus.
"""
__tablename__ = "workout_days"
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, unique=True)
day_number: int = Field(unique=True)
description: str = Field(default="")

37
app/models/workout_log.py Normal file
View File

@@ -0,0 +1,37 @@
"""WorkoutLog model for per-exercise set logging.
Each log entry records one set of one exercise within a session.
"""
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class WorkoutLog(SQLModel, table=True):
"""A single set log for an exercise within a workout session.
Attributes:
id: Primary key, auto-incremented.
session_id: FK to workout_sessions table.
exercise_id: FK to exercises table.
set_number: Which set this is (1, 2, 3...).
reps_completed: Actual reps performed.
weight_used: Weight used as string (e.g., "30 lbs", "BW").
felt_easy: Whether the user felt the set was easy (progression signal).
notes: Optional notes about this specific set.
created_at: Timestamp when the record was created.
"""
__tablename__ = "workout_logs"
id: Optional[int] = Field(default=None, primary_key=True)
session_id: int = Field(foreign_key="workout_sessions.id", index=True)
exercise_id: int = Field(foreign_key="exercises.id")
set_number: int = Field(default=1)
reps_completed: int = Field(default=0)
weight_used: str = Field(default="")
felt_easy: bool = Field(default=False)
notes: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -0,0 +1,31 @@
"""WorkoutSession model for tracking completed workout sessions.
Each session ties a user to a workout day on a specific date.
"""
import datetime as dt
from typing import Optional
from sqlmodel import Field, SQLModel
class WorkoutSession(SQLModel, table=True):
"""A completed workout session.
Attributes:
id: Primary key, auto-incremented.
user_id: FK to users table.
workout_day_id: FK to workout_days table.
date: The date the workout was performed.
notes: Optional free-text notes about the session.
created_at: Timestamp when the record was created.
"""
__tablename__ = "workout_sessions"
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id", index=True)
workout_day_id: int = Field(foreign_key="workout_days.id")
date: dt.date = Field(default_factory=dt.date.today)
notes: Optional[str] = Field(default=None)
created_at: dt.datetime = Field(default_factory=dt.datetime.utcnow)

0
app/routes/__init__.py Normal file
View File

23
app/routes/health.py Normal file
View File

@@ -0,0 +1,23 @@
"""Health check route for monitoring and readiness probes.
Provides a simple GET /health endpoint that returns application
status, name, and version.
"""
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
async def health_check() -> dict:
"""Return application health status.
Returns:
JSON with app name, version, and status.
"""
return {
"app": "sneakyswole",
"version": "0.1.0",
"status": "ok",
}

23
app/routes/pages.py Normal file
View File

@@ -0,0 +1,23 @@
"""Page routes for serving full HTML pages.
Renders Jinja2 templates for user-facing pages.
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
router = APIRouter(tags=["pages"])
@router.get("/")
async def home_page(request: Request) -> HTMLResponse:
"""Render the home page.
Args:
request: The incoming HTTP request.
Returns:
Rendered home page HTML.
"""
templates = request.app.state.templates
return templates.TemplateResponse(request, "pages/home.html")

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,88 @@
"""Service layer for exercise, warmup, and workout day data access.
All exercise-related database operations go through this service.
"""
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.exercise import Exercise
from app.models.warmup import Warmup
from app.models.workout_day import WorkoutDay
logger = structlog.get_logger(__name__)
class ExerciseService:
"""Handles read operations for exercises, warmups, and workout days.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def list_exercises(
self,
workout_day: Optional[str] = None,
muscle_group: Optional[str] = None,
) -> list[Exercise]:
"""List exercises with optional filtering.
Args:
workout_day: Filter by workout day name (e.g., "Push").
muscle_group: Filter by muscle group (e.g., "Chest").
Returns:
List of matching Exercise records.
"""
statement = select(Exercise)
if workout_day:
statement = statement.where(Exercise.workout_day == workout_day)
if muscle_group:
statement = statement.where(Exercise.muscle_group == muscle_group)
return list(self._session.exec(statement).all())
def get_exercise_by_id(self, exercise_id: int) -> Optional[Exercise]:
"""Retrieve an exercise by primary key.
Args:
exercise_id: The exercise ID.
Returns:
The Exercise record, or None if not found.
"""
return self._session.get(Exercise, exercise_id)
def get_exercise_by_name(self, name: str) -> Optional[Exercise]:
"""Retrieve an exercise by exact name match.
Args:
name: The exercise name to look up.
Returns:
The Exercise record, or None if not found.
"""
statement = select(Exercise).where(Exercise.name == name)
return self._session.exec(statement).first()
def list_warmups(self) -> list[Warmup]:
"""List all warmups in display order.
Returns:
List of Warmup records sorted by sort_order.
"""
statement = select(Warmup).order_by(Warmup.sort_order)
return list(self._session.exec(statement).all())
def list_workout_days(self) -> list[WorkoutDay]:
"""List all workout days in order.
Returns:
List of WorkoutDay records sorted by day_number.
"""
statement = select(WorkoutDay).order_by(WorkoutDay.day_number)
return list(self._session.exec(statement).all())

View File

@@ -0,0 +1,233 @@
"""Service for seeding the database from YAML config files.
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
from app.models.exercise import Exercise
from app.models.user import User
from app.models.user_exercise_program import UserExerciseProgram
from app.models.warmup import Warmup
from app.models.workout_day import WorkoutDay
logger = structlog.get_logger(__name__)
# Default workout day definitions
WORKOUT_DAYS = [
{"name": "Push", "day_number": 1, "description": "Chest, shoulders, triceps"},
{"name": "Pull", "day_number": 2, "description": "Back, biceps, traps"},
{"name": "Lower", "day_number": 3, "description": "Quads, hamstrings, glutes, calves"},
{"name": "Full Body", "day_number": 4, "description": "Compound full-body movements"},
]
class SeedService:
"""Seeds the database from YAML configuration files.
Args:
session: An active SQLModel Session.
config_dir: Path to the config directory containing YAML files.
"""
def __init__(self, session: Session, config_dir: Optional[Path] = None) -> None:
self._session = session
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.
"""
filepath = self._config_dir / filename
with open(filepath, "r") as f:
return yaml.safe_load(f)
def seed_workout_days(self) -> None:
"""Seed the 4 workout day records if they don't already exist."""
existing = self._session.exec(select(WorkoutDay)).first()
if existing:
logger.info("seed_skipped", table="workout_days", reason="already seeded")
return
for day_data in WORKOUT_DAYS:
day = WorkoutDay(**day_data)
self._session.add(day)
self._session.commit()
logger.info("seed_complete", table="workout_days", count=len(WORKOUT_DAYS))
def seed_exercises(self) -> None:
"""Seed exercises from config/exercises.yaml if table is empty."""
existing = self._session.exec(select(Exercise)).first()
if existing:
logger.info("seed_skipped", table="exercises", reason="already seeded")
return
data = self._load_yaml("exercises.yaml")
exercises = data.get("exercises", [])
for ex in exercises:
exercise = Exercise(
name=ex["name"],
muscle_group=ex["muscle_group"],
workout_day=ex["workout_day"],
sets=ex.get("sets", 3),
tempo=ex.get("tempo", ""),
form_cues=ex.get("form_cues", "").strip(),
)
self._session.add(exercise)
self._session.commit()
logger.info("seed_complete", table="exercises", count=len(exercises))
def seed_warmups(self) -> None:
"""Seed warmups from config/exercises.yaml if table is empty."""
existing = self._session.exec(select(Warmup)).first()
if existing:
logger.info("seed_skipped", table="warmups", reason="already seeded")
return
data = self._load_yaml("exercises.yaml")
warmups = data.get("warmup", [])
for i, wu in enumerate(warmups, start=1):
warmup = Warmup(
name=wu["name"],
type=wu.get("type", ""),
reps=wu.get("reps", ""),
form_cues=wu.get("form_cues", "").strip(),
sort_order=i,
)
self._session.add(warmup)
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.
"""
data = self._load_yaml("user_programs.yaml")
programs = data.get("programs", [])
for program in programs:
username = program["user"].lower().replace(" ", "_")
display_name = program["user"]
profile = program.get("profile", {})
# Check if user already exists
existing_user = self._session.exec(
select(User).where(User.username == username)
).first()
if existing_user:
user = existing_user
logger.info("seed_skipped", table="users", user=username, reason="already exists")
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()
self._session.refresh(user)
logger.info("seed_complete", table="users", user=username)
# Link exercises to user
for ex_data in program.get("exercises", []):
exercise = self._session.exec(
select(Exercise).where(Exercise.name == ex_data["name"])
).first()
if exercise is None:
logger.warning("seed_exercise_not_found", name=ex_data["name"])
continue
# Check if program already exists
existing_program = self._session.exec(
select(UserExerciseProgram).where(
UserExerciseProgram.user_id == user.id,
UserExerciseProgram.exercise_id == exercise.id,
)
).first()
if existing_program:
continue
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", "")),
)
self._session.add(uep)
self._session.commit()
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.
"""
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")

View File

@@ -0,0 +1,128 @@
"""Service layer for user profile management.
All user database operations go through this service.
Routes should never query the users table directly.
"""
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.user import User
logger = structlog.get_logger(__name__)
class UserService:
"""Handles CRUD operations for User records.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
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.
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)
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.
"""
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.
"""
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.
"""
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:
"""Update fields on an existing user.
Args:
user_id: The user's ID.
**kwargs: Field names and new values to update.
Returns:
The updated User record.
Raises:
ValueError: If the user is not found.
"""
user = self.get_user_by_id(user_id)
if user is None:
raise ValueError(f"User with id {user_id} not found")
for key, value in kwargs.items():
if hasattr(user, key):
setattr(user, key, value)
self._session.add(user)
self._session.commit()
self._session.refresh(user)
logger.info("user_updated", user_id=user_id, fields=list(kwargs.keys()))
return user

View File

@@ -0,0 +1,36 @@
/* SneakySwole custom CSS overrides
Built on top of Pico CSS dark theme.
Keep Pico mostly untouched — only override what's necessary. */
/* Slightly bolder nav styling */
nav h1 {
margin-bottom: 0;
font-weight: 700;
}
/* Active nav link indicator */
nav a[aria-current="page"] {
font-weight: 700;
text-decoration: underline;
}
/* Spacing for main content area */
main.container {
padding-top: 1rem;
padding-bottom: 2rem;
}
/* Flash message styling */
.flash-success {
color: var(--pico-ins-color);
border-left: 4px solid var(--pico-ins-color);
padding: 0.5rem 1rem;
margin-bottom: 1rem;
}
.flash-error {
color: var(--pico-del-color);
border-left: 4px solid var(--pico-del-color);
padding: 0.5rem 1rem;
margin-bottom: 1rem;
}

47
app/templates/base.html Normal file
View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SneakySwole{% endblock %}</title>
<!-- Pico CSS (dark theme via data-theme="dark" on <html>) -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<!-- Custom overrides -->
<link rel="stylesheet" href="/static/css/sneakyswole.css">
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
crossorigin="anonymous"></script>
{% block head_extra %}{% endblock %}
</head>
<body>
<header class="container">
<nav>
<ul>
<li><strong><a href="/">SneakySwole</a></strong></li>
</ul>
<ul>
{% block nav_items %}
<!-- Phase 3 adds: profile switcher, login/logout -->
{% endblock %}
</ul>
</nav>
</header>
<main class="container">
{% block flash %}{% endblock %}
{% block content %}{% endblock %}
</main>
<footer class="container">
<small>SneakySwole — Open-source workout tracking</small>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}SneakySwole — Home{% endblock %}
{% block content %}
<hgroup>
<h1>SneakySwole</h1>
<p>Your open-source workout tracker</p>
</hgroup>
<p>Welcome to SneakySwole. Get started by logging in.</p>
{% endblock %}

0
app/utils/__init__.py Normal file
View File

0
data/.gitkeep Normal file
View File

23
docker-compose.yaml Normal file
View File

@@ -0,0 +1,23 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: sneakyswole
ports:
- "8000:8000"
volumes:
- sneakyswole-data:/app/data
env_file:
- .env
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

9
pyproject.toml Normal file
View File

@@ -0,0 +1,9 @@
[project]
name = "sneakyswole"
version = "0.1.0"
description = "Open-source workout tracking and programming app"
requires-python = ">=3.12"
[build-system]
requires = ["setuptools>=75.0"]
build-backend = "setuptools.backends._legacy:_Backend"

13
requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
fastapi>=0.115.0,<1.0.0
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
pyyaml>=6.0.0,<7.0.0
sqlmodel>=0.0.22,<1.0.0
alembic>=1.14.0,<2.0.0
httpx>=0.28.0,<1.0.0
pytest>=8.0.0,<9.0.0
pytest-asyncio>=0.24.0,<1.0.0
pydantic-settings>=2.7.0,<3.0.0

0
tests/__init__.py Normal file
View File

28
tests/conftest.py Normal file
View File

@@ -0,0 +1,28 @@
"""Shared test fixtures for the SneakySwole test suite."""
import os
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(autouse=True)
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):
yield
@pytest.fixture
def client() -> TestClient:
"""Create a FastAPI TestClient for integration tests."""
from app.main import create_app
app = create_app()
return TestClient(app)

12
tests/test_app.py Normal file
View File

@@ -0,0 +1,12 @@
"""Tests for the FastAPI application factory."""
from fastapi.testclient import TestClient
class TestCreateApp:
"""Tests for the application factory and basic setup."""
def test_app_starts_without_error(self, client: TestClient) -> None:
"""The app should initialize and serve requests."""
response = client.get("/health")
assert response.status_code == 200

47
tests/test_config.py Normal file
View File

@@ -0,0 +1,47 @@
"""Tests for the application configuration loader."""
import os
from unittest.mock import patch
from app.config import Settings, get_settings
class TestSettings:
"""Tests for the Settings configuration class."""
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

26
tests/test_database.py Normal file
View File

@@ -0,0 +1,26 @@
"""Tests for database engine and session management."""
from sqlmodel import Session
from app.database import get_engine, get_db_session
class TestDatabase:
"""Tests for database connection management."""
def test_get_engine_returns_engine(self) -> None:
"""get_engine should return a SQLAlchemy engine instance."""
engine = get_engine("sqlite:///data/test.db")
assert engine is not None
assert "sqlite" in str(engine.url)
def test_get_db_session_yields_session(self) -> None:
"""get_db_session should yield a usable SQLModel Session."""
engine = get_engine("sqlite:///:memory:")
session_gen = get_db_session(engine)
session = next(session_gen)
assert isinstance(session, Session)
try:
next(session_gen)
except StopIteration:
pass

View File

@@ -0,0 +1,95 @@
"""Tests for the ExerciseService class."""
from sqlmodel import SQLModel, Session, create_engine
from app.models.exercise import Exercise
from app.models.warmup import Warmup
from app.models.workout_day import WorkoutDay
from app.services.exercise_service import ExerciseService
class TestExerciseService:
"""Tests for exercise and warmup data access."""
def _setup(self):
"""Create an in-memory DB with seed data and return the service."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
service = ExerciseService(session)
# Seed workout days
for i, (name, desc) in enumerate([
("Push", "Chest, shoulders, triceps"),
("Pull", "Back, biceps, traps"),
("Lower", "Quads, hamstrings, glutes, calves"),
("Full Body", "Compound movements"),
], start=1):
session.add(WorkoutDay(name=name, day_number=i, description=desc))
# Seed a few exercises
session.add(Exercise(name="DB Chest Press", muscle_group="Chest", workout_day="Push", sets=3, tempo="3-1-2", form_cues="..."))
session.add(Exercise(name="DB Row", muscle_group="Back", workout_day="Pull", sets=3, tempo="3-1-2", form_cues="..."))
# Seed warmups
session.add(Warmup(name="Cat / Cow", type="Thoracic Mob", reps="8 reps", form_cues="...", sort_order=1))
session.add(Warmup(name="Fire Hydrant", type="Hip Mobility", reps="8 each", form_cues="...", sort_order=2))
session.commit()
return session, service
def test_list_exercises_all(self) -> None:
"""list_exercises should return all exercises."""
session, service = self._setup()
exercises = service.list_exercises()
assert len(exercises) == 2
session.close()
def test_list_exercises_by_workout_day(self) -> None:
"""list_exercises should filter by workout day."""
session, service = self._setup()
exercises = service.list_exercises(workout_day="Push")
assert len(exercises) == 1
assert exercises[0].name == "DB Chest Press"
session.close()
def test_list_exercises_by_muscle_group(self) -> None:
"""list_exercises should filter by muscle group."""
session, service = self._setup()
exercises = service.list_exercises(muscle_group="Back")
assert len(exercises) == 1
session.close()
def test_get_exercise_by_id(self) -> None:
"""get_exercise_by_id should return the correct exercise."""
session, service = self._setup()
exercises = service.list_exercises()
found = service.get_exercise_by_id(exercises[0].id)
assert found is not None
session.close()
def test_get_exercise_by_name(self) -> None:
"""get_exercise_by_name should return the correct exercise."""
session, service = self._setup()
found = service.get_exercise_by_name("DB Chest Press")
assert found is not None
assert found.muscle_group == "Chest"
session.close()
def test_list_warmups_ordered(self) -> None:
"""list_warmups should return warmups in sort_order."""
session, service = self._setup()
warmups = service.list_warmups()
assert len(warmups) == 2
assert warmups[0].name == "Cat / Cow"
assert warmups[1].name == "Fire Hydrant"
session.close()
def test_list_workout_days(self) -> None:
"""list_workout_days should return all 4 days in order."""
session, service = self._setup()
days = service.list_workout_days()
assert len(days) == 4
assert days[0].name == "Push"
assert days[3].name == "Full Body"
session.close()

30
tests/test_health.py Normal file
View File

@@ -0,0 +1,30 @@
"""Tests for the health check endpoint."""
from fastapi.testclient import TestClient
class TestHealthCheck:
"""Tests for GET /health."""
def test_health_returns_200(self, client: TestClient) -> None:
"""GET /health should return HTTP 200."""
response = client.get("/health")
assert response.status_code == 200
def test_health_returns_status_ok(self, client: TestClient) -> None:
"""GET /health should return a JSON body with status 'ok'."""
response = client.get("/health")
data = response.json()
assert data["status"] == "ok"
def test_health_includes_app_name(self, client: TestClient) -> None:
"""GET /health should include the application name."""
response = client.get("/health")
data = response.json()
assert data["app"] == "sneakyswole"
def test_health_includes_version(self, client: TestClient) -> None:
"""GET /health should include the application version."""
response = client.get("/health")
data = response.json()
assert "version" in data

22
tests/test_logging.py Normal file
View File

@@ -0,0 +1,22 @@
"""Tests for structured logging configuration."""
import structlog
from app.logging_config import setup_logging
class TestLogging:
"""Tests for the structlog configuration."""
def test_setup_logging_configures_structlog(self) -> None:
"""setup_logging should configure structlog without errors."""
setup_logging(log_level="info")
logger = structlog.get_logger("test")
assert logger is not None
def test_logger_can_log_message(self) -> None:
"""A configured logger should be able to emit a log message."""
setup_logging(log_level="debug")
logger = structlog.get_logger("test_module")
# Should not raise
logger.info("test message", key="value")

228
tests/test_models.py Normal file
View File

@@ -0,0 +1,228 @@
"""Tests for SQLModel model definitions."""
from datetime import datetime
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.models.exercise import Exercise
from app.models.warmup import Warmup
from app.models.workout_day import WorkoutDay
from app.models.user_exercise_program import UserExerciseProgram
from app.models.workout_session import WorkoutSession
from app.models.workout_log import WorkoutLog
from app.models.progress_log import ProgressLog
class TestModels:
"""Tests that all models can be instantiated and persisted."""
def _create_engine(self):
"""Create an in-memory SQLite engine with all tables."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
return engine
def test_user_model_roundtrip(self) -> None:
"""User model should persist and retrieve correctly."""
engine = self._create_engine()
user = User(
username="testuser",
password_hash="fakehash",
display_name="Test User",
height="6'0\"",
weight="200 lbs",
goals="Get strong",
is_admin=False,
)
with Session(engine) as session:
session.add(user)
session.commit()
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:
"""Exercise model should persist and retrieve correctly."""
engine = self._create_engine()
exercise = Exercise(
name="DB Chest Press (Floor)",
muscle_group="Chest",
workout_day="Push",
sets=3,
tempo="3-1-2",
form_cues="Lie on the floor...",
)
with Session(engine) as session:
session.add(exercise)
session.commit()
session.refresh(exercise)
assert exercise.id is not None
assert exercise.name == "DB Chest Press (Floor)"
def test_warmup_model_roundtrip(self) -> None:
"""Warmup model should persist and retrieve correctly."""
engine = self._create_engine()
warmup = Warmup(
name="Cat / Cow",
type="Thoracic Mob",
reps="8 reps",
form_cues="Start on hands and knees...",
sort_order=1,
)
with Session(engine) as session:
session.add(warmup)
session.commit()
session.refresh(warmup)
assert warmup.id is not None
def test_workout_day_model_roundtrip(self) -> None:
"""WorkoutDay model should persist and retrieve correctly."""
engine = self._create_engine()
day = WorkoutDay(
name="Push",
day_number=1,
description="Chest, shoulders, triceps",
)
with Session(engine) as session:
session.add(day)
session.commit()
session.refresh(day)
assert day.id is not None
assert day.day_number == 1
def test_user_exercise_program_model_roundtrip(self) -> None:
"""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")
exercise = Exercise(
name="Test Ex", muscle_group="Test",
workout_day="Push", sets=3, tempo="3-1-2", form_cues="..."
)
session.add(user)
session.add(exercise)
session.commit()
session.refresh(user)
session.refresh(exercise)
program = UserExerciseProgram(
user_id=user.id,
exercise_id=exercise.id,
wk1_reps="8",
wk4_reps="12",
wk1_weight="30 lbs",
wk4_weight="40 lbs",
)
session.add(program)
session.commit()
session.refresh(program)
assert program.id is not None
assert program.user_id == user.id
def test_workout_session_model_roundtrip(self) -> None:
"""WorkoutSession model should persist correctly."""
engine = self._create_engine()
with Session(engine) as session:
user = User(username="u", password_hash="h", display_name="U")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
session.add(user)
session.add(day)
session.commit()
session.refresh(user)
session.refresh(day)
ws = WorkoutSession(
user_id=user.id,
workout_day_id=day.id,
date=datetime.utcnow().date(),
)
session.add(ws)
session.commit()
session.refresh(ws)
assert ws.id is not None
def test_workout_log_model_roundtrip(self) -> None:
"""WorkoutLog model should persist correctly."""
engine = self._create_engine()
with Session(engine) as session:
user = User(username="u", password_hash="h", display_name="U")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
exercise = Exercise(
name="Ex", muscle_group="Test",
workout_day="Push", sets=3, tempo="3-1-2", form_cues="..."
)
session.add_all([user, day, exercise])
session.commit()
session.refresh(user)
session.refresh(day)
session.refresh(exercise)
ws = WorkoutSession(
user_id=user.id,
workout_day_id=day.id,
date=datetime.utcnow().date(),
)
session.add(ws)
session.commit()
session.refresh(ws)
log = WorkoutLog(
session_id=ws.id,
exercise_id=exercise.id,
set_number=1,
reps_completed=8,
weight_used="30 lbs",
felt_easy=False,
)
session.add(log)
session.commit()
session.refresh(log)
assert log.id is not None
assert log.felt_easy is False
def test_progress_log_model_roundtrip(self) -> None:
"""ProgressLog model should persist correctly."""
engine = self._create_engine()
with Session(engine) as session:
user = User(username="u", password_hash="h", 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)
pl = ProgressLog(
user_id=user.id,
exercise_id=exercise.id,
date=datetime.utcnow().date(),
suggested_reps=10,
suggested_weight="35 lbs",
actual_reps=10,
actual_weight="35 lbs",
progression_applied="reps_increase",
)
session.add(pl)
session.commit()
session.refresh(pl)
assert pl.id is not None
def test_all_models_importable_from_init(self) -> None:
"""All models should be importable from app.models."""
from app.models import (
User, Exercise, Warmup, WorkoutDay,
UserExerciseProgram, WorkoutSession, WorkoutLog, ProgressLog,
)
assert User is not None
assert Exercise is not None
assert Warmup is not None
assert WorkoutDay is not None
assert UserExerciseProgram is not None
assert WorkoutSession is not None
assert WorkoutLog is not None
assert ProgressLog is not None

22
tests/test_pages.py Normal file
View File

@@ -0,0 +1,22 @@
"""Tests for HTML page routes."""
from fastapi.testclient import TestClient
class TestHomePage:
"""Tests for GET /."""
def test_home_returns_200(self, client: TestClient) -> None:
"""GET / should return HTTP 200."""
response = client.get("/")
assert response.status_code == 200
def test_home_contains_app_name(self, client: TestClient) -> None:
"""GET / should render HTML containing the app name."""
response = client.get("/")
assert "SneakySwole" in response.text
def test_home_has_dark_theme(self, client: TestClient) -> None:
"""GET / should use the dark theme."""
response = client.get("/")
assert 'data-theme="dark"' in response.text

View File

@@ -0,0 +1,94 @@
"""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
from app.models.exercise import Exercise
from app.models.warmup import Warmup
from app.models.workout_day import WorkoutDay
from app.models.user_exercise_program import UserExerciseProgram
from app.services.seed_service import SeedService
class TestSeedService:
"""Tests for YAML-based database seeding."""
def _setup(self):
"""Create an in-memory DB and seed service."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
# Use the real config files from the project
config_dir = Path(__file__).resolve().parent.parent / "config"
service = SeedService(session, config_dir=config_dir)
return session, service
def test_seed_workout_days(self) -> None:
"""seed_workout_days should create 4 workout day records."""
session, service = self._setup()
service.seed_workout_days()
days = session.exec(select(WorkoutDay)).all()
assert len(days) == 4
names = {d.name for d in days}
assert names == {"Push", "Pull", "Lower", "Full Body"}
session.close()
def test_seed_exercises_from_yaml(self) -> None:
"""seed_exercises should load all exercises from exercises.yaml."""
session, service = self._setup()
service.seed_exercises()
exercises = session.exec(select(Exercise)).all()
assert len(exercises) == 20 # 5 Push + 5 Pull + 5 Lower + 5 Full Body
session.close()
def test_seed_warmups_from_yaml(self) -> None:
"""seed_warmups should load all warmups from exercises.yaml."""
session, service = self._setup()
service.seed_warmups()
warmups = session.exec(select(Warmup)).all()
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()
service.seed_exercises()
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
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()
users = session.exec(select(User)).all()
exercises = session.exec(select(Exercise)).all()
# Should not be doubled
assert len(exercises) == 20
session.close()

View File

@@ -0,0 +1,89 @@
"""Tests for the UserService class."""
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.services.user_service import UserService
class TestUserService:
"""Tests for user CRUD operations."""
def _setup(self):
"""Create an in-memory DB and service instance."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
service = UserService(session)
return session, service
def test_create_user(self) -> None:
"""create_user should insert a new user and return it."""
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"
session.close()
def test_get_user_by_id(self) -> None:
"""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"
)
found = service.get_user_by_id(created.id)
assert found is not None
assert found.username == "test"
session.close()
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")
assert found is not None
assert found.display_name == "Admin"
session.close()
def test_get_user_by_username_not_found(self) -> None:
"""get_user_by_username should return None for missing users."""
session, service = self._setup()
found = service.get_user_by_username("nonexistent")
assert found is None
session.close()
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")
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")
updated = service.update_user(user.id, display_name="New", weight="250 lbs")
assert updated.display_name == "New"
assert updated.weight == "250 lbs"
session.close()