Compare commits

...

13 Commits

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:30:19 -06:00
77d1bc4a25 chore: add uv.lock for reproducible dependency resolution
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:20:39 -06:00
89d70fcae7 Merge branch 'docs/cleanup-implementation-files' 2026-02-24 14:19:54 -06:00
53e62f694f docs: replace implementation plans with architecture and API reference docs
Remove phase implementation plans, design notes, and source spreadsheet
that are no longer needed. Add architecture.md, API_REFERENCE.md, and
database_schema.md for ongoing development and debugging reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:19:49 -06:00
26 changed files with 919 additions and 188 deletions

16
.dockerignore Normal file
View File

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

12
.env.production Normal file
View File

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

View File

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

View File

@@ -14,6 +14,7 @@ from sqlmodel import Session
from app.database import get_db_session from app.database import get_db_session
from app.models.user import User from app.models.user import User
from app.services.log_service import LogService from app.services.log_service import LogService
from app.services.progression_service import ProgressionService
from app.services.workout_session_service import WorkoutSessionService from app.services.workout_session_service import WorkoutSessionService
from app.utils.auth import get_current_admin_user, get_active_profile_id from app.utils.auth import get_current_admin_user, get_active_profile_id
@@ -22,6 +23,30 @@ logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/log", tags=["logging"]) router = APIRouter(prefix="/log", tags=["logging"])
def _get_prefill_values(
logs: list,
session: Session,
profile_id: int,
exercise_id: int,
) -> tuple:
"""Get pre-fill values for the next set form.
If sets have already been logged this session, use the last logged
set's values (users typically repeat the same reps/weight across sets).
Otherwise, use the progression engine's suggestion.
Returns:
(suggested_reps, suggested_weight) tuple.
"""
if logs:
last = logs[-1]
return last.reps_completed, last.weight_used
progression = ProgressionService(session)
suggestion = progression.get_suggestion(profile_id, exercise_id)
return suggestion.get("suggested_reps"), suggestion.get("suggested_weight")
@router.post("", response_class=HTMLResponse) @router.post("", response_class=HTMLResponse)
async def log_set( async def log_set(
request: Request, request: Request,
@@ -79,6 +104,9 @@ async def log_set(
# Return updated logs for this exercise # Return updated logs for this exercise
logs = log_service.list_logs_for_exercise(ws.id, exercise_id) logs = log_service.list_logs_for_exercise(ws.id, exercise_id)
next_set = len(logs) + 1 next_set = len(logs) + 1
suggested_reps, suggested_weight = _get_prefill_values(
logs, session, active_profile_id, exercise_id,
)
templates = request.app.state.templates templates = request.app.state.templates
return templates.TemplateResponse("partials/log_entry.html", { return templates.TemplateResponse("partials/log_entry.html", {
@@ -88,6 +116,8 @@ async def log_set(
"workout_day_id": workout_day_id, "workout_day_id": workout_day_id,
"next_set": next_set, "next_set": next_set,
"session_id": ws.id, "session_id": ws.id,
"suggested_reps": suggested_reps,
"suggested_weight": suggested_weight,
}) })
@@ -124,6 +154,13 @@ async def edit_log(
logs = log_service.list_logs_for_exercise(log.session_id, log.exercise_id) logs = log_service.list_logs_for_exercise(log.session_id, log.exercise_id)
next_set = len(logs) + 1 next_set = len(logs) + 1
active_profile_id = get_active_profile_id(request)
suggested_reps, suggested_weight = None, None
if active_profile_id:
suggested_reps, suggested_weight = _get_prefill_values(
logs, session, active_profile_id, log.exercise_id,
)
templates = request.app.state.templates templates = request.app.state.templates
return templates.TemplateResponse("partials/log_entry.html", { return templates.TemplateResponse("partials/log_entry.html", {
"request": request, "request": request,
@@ -132,6 +169,8 @@ async def edit_log(
"workout_day_id": 0, "workout_day_id": 0,
"next_set": next_set, "next_set": next_set,
"session_id": log.session_id, "session_id": log.session_id,
"suggested_reps": suggested_reps,
"suggested_weight": suggested_weight,
}) })
@@ -164,6 +203,13 @@ async def delete_log(
logs = log_service.list_logs_for_exercise(session_id, exercise_id) logs = log_service.list_logs_for_exercise(session_id, exercise_id)
next_set = len(logs) + 1 next_set = len(logs) + 1
active_profile_id = get_active_profile_id(request)
suggested_reps, suggested_weight = None, None
if active_profile_id:
suggested_reps, suggested_weight = _get_prefill_values(
logs, session, active_profile_id, exercise_id,
)
templates = request.app.state.templates templates = request.app.state.templates
return templates.TemplateResponse("partials/log_entry.html", { return templates.TemplateResponse("partials/log_entry.html", {
"request": request, "request": request,
@@ -172,6 +218,8 @@ async def delete_log(
"workout_day_id": 0, "workout_day_id": 0,
"next_set": next_set, "next_set": next_set,
"session_id": session_id, "session_id": session_id,
"suggested_reps": suggested_reps,
"suggested_weight": suggested_weight,
}) })
return HTMLResponse("") return HTMLResponse("")

View File

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

View File

@@ -32,6 +32,10 @@
<!-- Inline logging (Phase 4) --> <!-- Inline logging (Phase 4) -->
{% if active_profile %} {% if active_profile %}
<div id="logs-exercise-{{ exercise.id }}"> <div id="logs-exercise-{{ exercise.id }}">
{% if suggestions and suggestions[exercise.id] %}
{% set suggested_reps = suggestions[exercise.id].suggested_reps %}
{% set suggested_weight = suggestions[exercise.id].suggested_weight %}
{% endif %}
{% if existing_logs and existing_logs[exercise.id] %} {% if existing_logs and existing_logs[exercise.id] %}
{% set logs = existing_logs[exercise.id] %} {% set logs = existing_logs[exercise.id] %}
{% set exercise_id = exercise.id %} {% set exercise_id = exercise.id %}

View File

@@ -11,9 +11,11 @@
<small style="white-space:nowrap; opacity:0.7;">Set {{ next_set|default(1) }}</small> <small style="white-space:nowrap; opacity:0.7;">Set {{ next_set|default(1) }}</small>
<input type="number" name="reps" placeholder="Reps" <input type="number" name="reps" placeholder="Reps"
min="0" max="100" required min="0" max="100" required
{% if suggested_reps %}value="{{ suggested_reps }}"{% endif %}
style="width:5rem; margin-bottom:0;"> style="width:5rem; margin-bottom:0;">
<input type="text" name="weight" placeholder="Weight (lbs)" <input type="text" name="weight" placeholder="Weight (lbs)"
required required
{% if suggested_weight %}value="{{ suggested_weight }}"{% endif %}
style="width:8rem; margin-bottom:0;"> style="width:8rem; margin-bottom:0;">
<label style="display:flex; align-items:center; gap:0.3rem; margin-bottom:0; white-space:nowrap;"> <label style="display:flex; align-items:center; gap:0.3rem; margin-bottom:0; white-space:nowrap;">
<input type="checkbox" name="felt_easy" role="switch" style="margin-bottom:0;"> <input type="checkbox" name="felt_easy" role="switch" style="margin-bottom:0;">

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

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

View File

@@ -1,11 +1,9 @@
services: services:
app: app:
build: image: git.sneakygeek.net/sneakygeek/sneakyswole:latest
context: .
dockerfile: Dockerfile
container_name: sneakyswole container_name: sneakyswole
ports: ports:
- "8000:8000" - "${APP_PORT:-8000}:8000"
volumes: volumes:
- sneakyswole-data:/app/data - sneakyswole-data:/app/data
env_file: env_file:

278
docs/API_REFERENCE.md Normal file
View File

@@ -0,0 +1,278 @@
# API Reference
All endpoints return HTML (full pages or HTMX partials) unless noted otherwise. Protected routes require a valid admin session cookie.
---
## Authentication
### `GET /login`
Render the login form.
- **Auth:** None
- **Response:** Full page (`pages/login.html`)
### `POST /login`
Authenticate admin credentials and start a session.
- **Auth:** None
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `username` (string) — admin username
- `password` (string) — admin password
- **Success:** 303 redirect to `/`, sets `session` cookie (httponly, samesite=lax, 24h TTL)
- **Failure:** 200 with login page re-rendered and error message
### `GET /logout`
End the admin session.
- **Auth:** None (clears cookies regardless)
- **Response:** 303 redirect to `/login`, deletes `session` and `active_profile_id` cookies
---
## Health
### `GET /health`
Application health check for monitoring and readiness probes.
- **Auth:** None
- **Response:** JSON
```json
{
"app": "sneakyswole",
"version": "0.1.0",
"status": "ok"
}
```
---
## Pages
### `GET /`
Home page.
- **Auth:** None
- **Response:** Full page (`pages/home.html`)
---
## Profiles
All profile routes require admin authentication.
### `GET /profiles`
List all user profiles (excludes admin).
- **Auth:** Admin session
- **Response:** Full page (`pages/profiles.html`)
- **Template context:** `profiles`, `active_profile_id`, `admin`
### `POST /profiles/switch`
Switch the active user profile.
- **Auth:** Admin session
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `profile_id` (string) — ID of the profile to activate
- **Response:** 303 redirect to referring page, sets `active_profile_id` cookie
### `GET /profiles/{profile_id}/edit`
Render the profile edit form.
- **Auth:** Admin session
- **Path params:** `profile_id` (int)
- **Response:** Full page (`pages/profile_edit.html`)
### `POST /profiles/{profile_id}/edit`
Update a user profile.
- **Auth:** Admin session
- **Path params:** `profile_id` (int)
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `display_name` (string)
- `height` (string)
- `weight` (string)
- `goals` (string)
- **Response:** 303 redirect to `/profiles`
---
## Workouts
All workout routes require admin authentication.
### `GET /workouts`
List all workout days as clickable cards.
- **Auth:** Admin session
- **Response:** Full page (`pages/workout_days.html`)
- **Template context:** `days`, `admin`
### `GET /workouts/{day_name}`
Display a full workout day with warmups, exercises, programming targets, and inline logging.
- **Auth:** Admin session
- **Path params:** `day_name` (string) — URL-friendly name, e.g., "push", "pull", "lower", "full-body"
- **Response:** Full page (`pages/workout_day.html`)
- **Template context:** `day_name`, `warmups`, `exercises`, `programs`, `active_profile`, `existing_logs`, `suggestions`, `workout_day_id`, `admin`
- **Notes:** Day name is normalized (e.g., "full-body" → "Full Body"). If an active profile is set, includes programming targets, progression suggestions, and today's existing logs.
---
## Exercises
All exercise routes require admin authentication.
### `GET /exercises`
Render the exercise browser with filter dropdowns.
- **Auth:** Admin session
- **Response:** Full page (`pages/exercise_browser.html`)
- **Template context:** `exercises`, `workout_days`, `muscle_groups`, `admin`
### `GET /exercises/search`
HTMX partial — return filtered exercise list.
- **Auth:** Admin session
- **Query params:**
- `workout_day` (string, optional) — filter by workout day name
- `muscle_group` (string, optional) — filter by muscle group
- **Response:** HTMX partial (`partials/exercise_list.html`)
- **Usage:** Called via `hx-get` from exercise browser filter dropdowns
---
## Workout Logging
All logging routes require admin authentication. Responses are HTMX partials that update inline.
### `POST /log`
Log a single set for an exercise. Auto-creates today's workout session if needed.
- **Auth:** Admin session
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `exercise_id` (int)
- `workout_day_id` (int)
- `set_number` (int, default=1)
- `reps` (int)
- `weight` (string) — e.g., "30 lbs", "BW"
- `felt_easy` (checkbox, "on" = true)
- **Response:** HTMX partial (`partials/log_entry.html`) with updated logs for this exercise
- **Error:** If no active profile selected, returns `partials/flash_message.html` with error
### `POST /log/{log_id}/edit`
Edit an existing log entry.
- **Auth:** Admin session
- **Path params:** `log_id` (int)
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `reps` (int)
- `weight` (string)
- `felt_easy` (checkbox)
- `notes` (string, optional)
- **Response:** HTMX partial (`partials/log_entry.html`) with updated logs
### `POST /log/{log_id}/delete`
Delete a log entry.
- **Auth:** Admin session
- **Path params:** `log_id` (int)
- **Response:** HTMX partial (`partials/log_entry.html`) with remaining logs, or empty HTML if log not found
---
## History
All history routes require admin authentication.
### `GET /history`
Display log history for the active profile — list of past sessions, most recent first.
- **Auth:** Admin session
- **Response:** Full page (`pages/log_history.html`)
- **Template context:** `sessions`, `days_by_id`, `active_profile`, `admin`
- **Notes:** Requires active profile to show sessions
### `GET /history/{session_id}`
Display detailed logs for a specific workout session, grouped by exercise.
- **Auth:** Admin session
- **Path params:** `session_id` (int)
- **Response:** Full page (`pages/session_detail.html`)
- **Template context:** `workout_session`, `logs_by_exercise`, `exercises_by_id`, `days_by_id`, `admin`
---
## Dashboard
All dashboard routes require admin authentication.
### `GET /dashboard`
Render the progress dashboard with summary stats, volume chart, and exercise links.
- **Auth:** Admin session
- **Response:** Full page (`pages/dashboard.html`)
- **Template context:** `stats`, `volume_data_json`, `exercises`, `active_profile`, `admin`
- **Notes:** Stats and volume data require an active profile. Chart.js renders client-side charts from JSON embedded in the template.
### `GET /dashboard/exercise/{exercise_id}`
Per-exercise progress page with charts and progression suggestions.
- **Auth:** Admin session
- **Path params:** `exercise_id` (int)
- **Response:** Full page (`pages/exercise_progress.html`)
- **Template context:** `exercise`, `progress_data_json`, `suggestion`, `active_profile`, `admin`
---
## Schedule
### `GET /schedule`
4-week calendar view showing which workout day maps to which date.
- **Auth:** Admin session
- **Response:** Full page (`pages/schedule.html`)
- **Template context:** `weeks`, `active_profile`, `admin`
- **Notes:** Calendar starts from Monday of the current week. Days with completed sessions are highlighted. Requires active profile for session completion data.
---
## Authentication Details
### Cookies
| Cookie | Purpose | Flags | TTL |
|--------|---------|-------|-----|
| `session` | Admin session token (itsdangerous signed) | httponly, samesite=lax | 24 hours |
| `active_profile_id` | Currently selected user profile ID | httponly, samesite=lax | 24 hours |
### Auth Failure Behavior
- Missing or invalid session cookie → 302 redirect to `/login`
- Handled via `NotAuthenticatedError` exception + registered handler in `app/main.py`

View File

@@ -1,6 +1,6 @@
# Documentation Folder # Documentation
This folder contains business planning, architecture decisions, and documentation. This folder contains architecture, API reference, and development documentation.
**No application code belongs here.** **No application code belongs here.**
@@ -8,11 +8,12 @@ This folder contains business planning, architecture decisions, and documentatio
| Document | Purpose | | Document | Purpose |
|----------|---------| |----------|---------|
| `code_guidelines.md` | Code creation guidelines and Git strategy | | `architecture.md` | App architecture, layers, patterns, request lifecycle |
| `security.md` | Python focused security code suggestions | | `API_REFERENCE.md` | All 23 endpoints with params, auth, and response details |
| `roadmap.md` | 5-phase development roadmap | | `database_schema.md` | All 8 tables, columns, relationships, migrations |
| `plans/` | Design documents and implementation plans | | `code_guidelines.md` | Coding standards and Git strategy |
| `security.md` | Python-focused security guidance |
| `roadmap.md` | Feature status and future plans |
## Guidelines ## Guidelines

197
docs/architecture.md Normal file
View File

@@ -0,0 +1,197 @@
# Architecture
## Overview
SneakySwole is a workout tracking and programming app built with FastAPI, HTMX, and SQLite. It follows a layered architecture with strict separation of concerns: routes handle HTTP, services handle business logic and data access, and models define the schema.
All user interactions return HTML (full pages or HTMX partials) — there are no JSON APIs except the health check.
---
## Technology Stack
| Layer | Technology | Notes |
|-------|-----------|-------|
| Web framework | FastAPI + Uvicorn | Async, Python 3.12 |
| Frontend | Jinja2 + HTMX + Pico CSS | Dark theme, no JS framework |
| Database | SQLite3 + SQLModel ORM | Single file at `data/sneakyswole.db` |
| Migrations | Alembic | Schema versioning, auto-generated DDL |
| Auth | bcrypt + itsdangerous | Hashed passwords, signed session cookies |
| Logging | structlog | Structured JSON logging |
| Config | pydantic-settings | Typed `.env` loader with validation |
| Container | Docker + docker-compose | Single service, port 8000, named volume |
| Testing | pytest | 30+ test modules |
| Dependencies | uv + requirements.txt | Pinned versions |
---
## Application Layers
### 1. Routes (`app/routes/`)
HTTP handlers that parse requests, call services, and return rendered templates. Each file covers one domain.
| File | Prefix | Purpose |
|------|--------|---------|
| `auth.py` | `/login`, `/logout` | Login form, credential verification, session cookies |
| `pages.py` | `/` | Home page |
| `profiles.py` | `/profiles` | Profile list, switch, edit |
| `workouts.py` | `/workouts` | Workout day list and detail viewer |
| `exercises.py` | `/exercises` | Exercise browser with HTMX search |
| `logging.py` | `/log` | Inline set logging (create/edit/delete) |
| `history.py` | `/history` | Past session list and detail |
| `dashboard.py` | `/dashboard` | Progress stats, volume charts, per-exercise progress |
| `schedule.py` | `/schedule` | 4-week calendar view |
| `health.py` | `/health` | JSON health check |
**Rules:**
- Routes never query the database directly — all data access goes through services
- All routes (except `/login`, `/logout`, `/health`) require admin authentication via `@Depends(get_current_admin_user)`
- Routes return `TemplateResponse` (HTML), not JSON
### 2. Services (`app/services/`)
Business logic and all database access. Each service is instantiated with a SQLModel `Session`.
| Service | File | Responsibility |
|---------|------|---------------|
| `AuthService` | `auth_service.py` | bcrypt password verification, session token creation/validation (itsdangerous) |
| `UserService` | `user_service.py` | User CRUD, list profiles, update stats |
| `ExerciseService` | `exercise_service.py` | Exercise queries (by day, muscle group), warmup listing, workout day listing |
| `LogService` | `log_service.py` | Workout log CRUD (create/read/update/delete sets) |
| `WorkoutSessionService` | `workout_session_service.py` | Session management (get_or_create, list, lookup) |
| `ProgressionService` | `progression_service.py` | Auto-progression suggestions (+reps/+weight/deload) |
| `AnalyticsService` | `analytics_service.py` | User stats, volume by day, per-exercise progress data |
| `SeedService` | `seed_service.py` | YAML-driven database initialization from `config/` files |
### 3. Models (`app/models/`)
SQLModel ORM classes defining 8 database tables. See `docs/database_schema.md` for full details.
### 4. Utils (`app/utils/`)
Shared utilities — currently just auth dependencies:
- **`auth.py`** — `get_current_admin_user()` (FastAPI dependency), `get_active_profile_id()`, `NotAuthenticatedError`, `SESSION_COOKIE_NAME`
### 5. Templates (`app/templates/`)
Jinja2 templates split into full pages and reusable HTMX partials.
- **`base.html`** — Master layout with Pico CSS dark theme, HTMX script, nav bar
- **`pages/`** — 12 full-page templates (login, home, dashboard, workout_day, etc.)
- **`partials/`** — 13 HTMX fragment templates (exercise_card, log_form, nav, stats_card, etc.)
### 6. Configuration
- **`app/config.py`** — Typed `Settings` class (pydantic-settings), singleton via `@lru_cache`
- **`.env`** — Runtime secrets (gitignored), referenced by docker-compose
- **`.env.example`** — Template documenting required variables
- **`config/exercises.yaml`** — Exercise library (name, muscle group, sets, tempo, form cues)
- **`config/user_programs.yaml`** — Per-user programming targets (week 1/4 reps and weights)
---
## Key Patterns
### Authentication Flow
1. Admin submits credentials via `POST /login`
2. `AuthService.authenticate()` verifies bcrypt hash
3. On success, `AuthService.create_session_token()` creates a signed token (itsdangerous)
4. Token stored in httponly cookie (`session`), samesite=lax, 24h TTL
5. `get_current_admin_user()` dependency validates token on every protected route
6. Invalid/missing token raises `NotAuthenticatedError` → 302 redirect to `/login`
### Profile Switching
- Admin selects active profile via `POST /profiles/switch`
- Profile ID stored in separate httponly cookie (`active_profile_id`)
- `get_active_profile_id()` extracts it from the request
- Workout logging happens under the active profile
### NavContextMiddleware
Starlette middleware (`app/main.py:NavContextMiddleware`) runs on every request:
1. Reads session cookie and validates token
2. If valid admin: loads `admin`, `profiles` list, and `active_profile` into `request.state`
3. Templates read from `request.state` to render the nav bar (profile switcher, etc.)
### HTMX Partial Pattern
All dynamic updates use HTMX with HTML fragment responses:
- Filter dropdowns trigger `hx-get` to `/exercises/search` → returns `partials/exercise_list.html`
- Log form submits `hx-post` to `/log` → returns `partials/log_entry.html`
- No JSON APIs, no fetch calls, no vanilla JS
### Database Startup
1. `create_app()` creates SQLModel engine and calls `SQLModel.metadata.create_all()`
2. `@app.on_event("startup")` triggers `SeedService.seed_all()`
3. Seed service reads `config/exercises.yaml` + `config/user_programs.yaml`
4. Inserts exercises, warmups, workout days, user profiles, and programming targets
5. Creates admin user from `.env` credentials (bcrypt-hashed)
6. Skips seeding if data already exists
---
## Request Lifecycle
```
Client Request
NavContextMiddleware (injects admin/profiles/active_profile into request.state)
FastAPI Router (matches route)
Auth Dependency (get_current_admin_user — validates session cookie)
Route Handler (parses request, calls service(s))
Service Layer (business logic, DB queries via SQLModel Session)
Jinja2 TemplateResponse (renders full page or HTMX partial)
Client Response (HTML)
```
---
## Docker Setup
- **`Dockerfile`** — Slim Python 3.12 base, copies app + config, installs deps, runs Uvicorn
- **`docker-compose.yaml`** — Single `app` service, port 8000, named volume for `data/`, `.env` file
- Static files and templates are baked into the image
- SQLite DB persists via Docker volume mount at `data/`
---
## Directory Structure
```
SneakySwole/
├── app/
│ ├── __init__.py
│ ├── config.py # Typed settings (pydantic-settings)
│ ├── database.py # Engine factory + session dependency
│ ├── logging_config.py # structlog setup
│ ├── main.py # App factory, middleware, router registration
│ ├── models/ # SQLModel ORM (8 tables)
│ ├── routes/ # FastAPI handlers (10 modules)
│ ├── services/ # Business logic + DB access (8 services)
│ ├── static/css/ # Pico CSS overrides
│ ├── templates/ # Jinja2 (pages/ + partials/)
│ └── utils/ # Auth dependencies
├── config/ # YAML seed data
│ ├── exercises.yaml
│ └── user_programs.yaml
├── alembic/ # Database migrations
├── tests/ # pytest test suite
├── data/ # SQLite DB (gitignored, Docker volume)
├── docs/ # Documentation
├── Dockerfile
├── docker-compose.yaml
├── pyproject.toml
├── requirements.txt
└── .env.example
```

202
docs/database_schema.md Normal file
View File

@@ -0,0 +1,202 @@
# Database Schema
SQLite database at `data/sneakyswole.db`, managed by SQLModel ORM with Alembic migrations.
---
## Tables
### `users`
User profiles (admin and regular). Admin has login credentials; regular profiles are managed by the admin.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `username` | VARCHAR | UNIQUE, indexed | Login identifier |
| `password_hash` | VARCHAR | default="" | bcrypt hash (admin only) |
| `display_name` | VARCHAR | default="" | Name shown in UI |
| `height` | VARCHAR | nullable | e.g., "6'0\"" |
| `weight` | VARCHAR | nullable | e.g., "260 lbs" |
| `goals` | VARCHAR | nullable | Free-text training goals |
| `is_admin` | BOOLEAN | default=False | Admin privileges flag |
| `created_at` | DATETIME | auto | Record creation time |
| `updated_at` | DATETIME | auto | Last update time |
**Model:** `app/models/user.py:User`
---
### `exercises`
Exercise library catalog. Each exercise belongs to a workout day.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `name` | VARCHAR | indexed | e.g., "DB Chest Press (Floor)" |
| `muscle_group` | VARCHAR | default="" | e.g., "Chest", "Shoulders" |
| `workout_day` | VARCHAR | indexed | "Push", "Pull", "Lower", "Full Body" |
| `sets` | INTEGER | default=3 | Default number of sets |
| `tempo` | VARCHAR | default="" | e.g., "3-1-2" (eccentric-pause-concentric) |
| `form_cues` | VARCHAR | default="" | Detailed form instructions |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/exercise.py:Exercise`
---
### `warmups`
Standardized warmup routine displayed before every workout.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `name` | VARCHAR | indexed | e.g., "Cat / Cow" |
| `type` | VARCHAR | default="" | Category: "Thoracic Mob", "Hip Mobility", etc. |
| `reps` | VARCHAR | default="" | e.g., "8 reps", "8 each side" |
| `form_cues` | VARCHAR | default="" | Detailed form instructions |
| `sort_order` | INTEGER | default=0 | Display order in warmup sequence |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/warmup.py:Warmup`
---
### `workout_days`
The 4-day training split definition.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `name` | VARCHAR | UNIQUE, indexed | "Push", "Pull", "Lower", "Full Body" |
| `day_number` | INTEGER | UNIQUE | Order in rotation (1-4) |
| `description` | VARCHAR | default="" | Brief focus description |
**Model:** `app/models/workout_day.py:WorkoutDay`
---
### `user_exercise_programs`
Per-user programming targets linking users to exercises with week 1/4 goals.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `user_id` | INTEGER | FK → `users.id`, indexed | Profile this applies to |
| `exercise_id` | INTEGER | FK → `exercises.id`, indexed | Exercise being programmed |
| `wk1_reps` | VARCHAR | default="" | Week 1 target reps (e.g., "10", "30 sec") |
| `wk4_reps` | VARCHAR | default="" | Week 4 target reps |
| `wk1_weight` | VARCHAR | default="" | Week 1 target weight (e.g., "30 lbs", "BW") |
| `wk4_weight` | VARCHAR | default="" | Week 4 target weight |
| `created_at` | DATETIME | auto | Record creation time |
| `updated_at` | DATETIME | auto | Last update time |
**Model:** `app/models/user_exercise_program.py:UserExerciseProgram`
---
### `workout_sessions`
A completed workout session — ties a user to a workout day on a date.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `user_id` | INTEGER | FK → `users.id`, indexed | Who did the workout |
| `workout_day_id` | INTEGER | FK → `workout_days.id` | Which day was trained |
| `date` | DATE | default=today | Date the workout was performed |
| `notes` | VARCHAR | nullable | Free-text session notes |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/workout_session.py:WorkoutSession`
---
### `workout_logs`
Individual set logs within a session. Each row = one set of one exercise.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `session_id` | INTEGER | FK → `workout_sessions.id`, indexed | Parent session |
| `exercise_id` | INTEGER | FK → `exercises.id` | Which exercise |
| `set_number` | INTEGER | default=1 | Set number (1, 2, 3...) |
| `reps_completed` | INTEGER | default=0 | Actual reps performed |
| `weight_used` | VARCHAR | default="" | Weight used (e.g., "30 lbs", "BW") |
| `felt_easy` | BOOLEAN | default=False | Progression signal |
| `notes` | VARCHAR | nullable | Per-set notes |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/workout_log.py:WorkoutLog`
---
### `progress_log`
Progression tracking — what the engine suggested vs what the user actually did.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `user_id` | INTEGER | FK → `users.id`, indexed | Profile being tracked |
| `exercise_id` | INTEGER | FK → `exercises.id` | Exercise being tracked |
| `date` | DATE | default=today | Date of progression entry |
| `suggested_reps` | INTEGER | nullable | Engine recommendation |
| `suggested_weight` | VARCHAR | nullable | Engine recommendation |
| `actual_reps` | INTEGER | nullable | What user actually did |
| `actual_weight` | VARCHAR | nullable | What user actually used |
| `progression_applied` | VARCHAR | nullable | Type: "reps_increase", "weight_increase", "deload" |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/progress_log.py:ProgressLog`
---
## Relationships
```
users
├── user_exercise_programs (1:N via user_id)
├── workout_sessions (1:N via user_id)
└── progress_log (1:N via user_id)
exercises
├── user_exercise_programs (1:N via exercise_id)
├── workout_logs (1:N via exercise_id)
└── progress_log (1:N via exercise_id)
workout_days
└── workout_sessions (1:N via workout_day_id)
workout_sessions
└── workout_logs (1:N via session_id)
```
---
## Migrations
Managed by Alembic. Config at `alembic.ini`, migration scripts at `alembic/versions/`.
- **Initial migration:** `1855836abf6c_initial_schema_8_tables.py` — creates all 8 tables
To create a new migration:
```bash
alembic revision --autogenerate -m "description"
alembic upgrade head
```
---
## Seeding
On first startup, `SeedService.seed_all()` reads:
- `config/exercises.yaml` — exercise catalog + warmups + workout days
- `config/user_programs.yaml` — per-user week 1/4 targets
Admin user is created from `ADMIN_USERNAME` / `ADMIN_PASSWORD` env vars with bcrypt hash. Seeding is skipped if data already exists.

View File

@@ -1,76 +0,0 @@
# Design: Roadmap, Exercise Data, and Auth Model
**Date:** 2026-02-23
**Status:** Approved
## Overview
This document captures the design decisions for the SneakySwole roadmap structure, exercise data management, and authentication model.
## Roadmap Structure
Five phases, each producing a testable milestone:
1. **Scaffold & Infrastructure** — project structure, Docker, base template, logging
2. **Data Layer & Seeding** — SQLite schema, Alembic migrations, YAML-based exercise/program seeding
3. **Workout UI** — admin auth, profile management, workout viewer, exercise browser
4. **Logging & Tracking** — per-exercise logging, session tracking, log history
5. **Progression & Analytics** — auto-progression, schedule view, progress dashboard
Full details in `docs/roadmap.md`.
## Exercise Data Design
### Separated YAML Files
Exercise data is split into two config files for clean separation of concerns:
- **`config/exercises.yaml`** — Universal exercise library
- Exercise name, muscle group, workout day, sets, tempo, form cues
- Warmup exercises (name, type, reps/time, form cues)
- Rarely changes; defines the available exercise catalog
- **`config/user_programs.yaml`** — Per-user programming
- References exercises by name
- Week 1 and week 4 reps and weights per user per exercise
- Changes when users adjust their programming or new users are added
### Seeding Flow
1. On first run (empty DB), the seed script reads both YAML files
2. Exercises and warmups are inserted into their respective tables
3. User profiles are created (admin from `.env`, others from user_programs.yaml)
4. User-specific programming (reps/weights) is linked to exercises and users
5. Subsequent runs skip seeding if data already exists
### Why YAML Over Spreadsheet
- Human-readable and version-controllable
- Easy to update without special tools
- Can be validated with schema checks
- Spreadsheet remains source of truth for initial data extraction only
## Authentication Model
### Phase 1: Simple Admin Auth
- Single admin user with credentials stored in `.env` (`ADMIN_USERNAME`, `ADMIN_PASSWORD`)
- On first run, admin user is created in DB with bcrypt-hashed password
- Admin logs in via a simple login form (session-based auth)
- Admin creates user profiles through the UI (no login for profiles)
- Profile switcher in navigation allows admin to select the active profile
- All workout logging happens under the selected profile
### Security Considerations
- Passwords hashed with bcrypt (never stored plaintext)
- `.env` file is gitignored and never committed
- `.env.example` documents required variables without real values
- Session tokens with reasonable expiry
- Admin-only routes protected by auth middleware
### Future Upgrade Path
- Phase 1 auth is designed to be replaceable
- If multi-user login is needed later, add login credentials to the user profiles table
- Profile switcher becomes unnecessary when each user logs in independently

View File

@@ -1,85 +1,30 @@
# SneakySwole Roadmap # SneakySwole Roadmap
## Phase 1: Scaffold & Infrastructure ## Completed
Set up the project foundation — everything needed before writing features. ### Phase 1: Scaffold & Infrastructure
FastAPI project structure, Dockerfile + docker-compose.yaml, Pico CSS dark theme base template, `.env` config, structlog logging, health check endpoint.
- FastAPI project structure (`app/` with routes, services, models, templates, static, utils) ### Phase 2: Data Layer & Seeding
- Dockerfile + docker-compose.yaml (SQLite volume, port 8000, `.env` support) 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.
- Pico CSS dark theme base template (Jinja2 with `data-theme="dark"`)
- `.env` / `.env.example` with admin credentials (`ADMIN_USERNAME`, `ADMIN_PASSWORD`)
- `uv` for dependency management, `requirements.txt` with pinned versions
- Structlog logging setup
- Basic health check route (`/health`)
## Phase 2: Data Layer & Seeding ### 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.
Build the database schema and seed it from YAML config files. ### 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.
- SQLite schema via Alembic migrations ### Phase 5: Progression & Analytics
- Tables: users, exercises, warmups, workout_days, user_exercise_programs, sets, progress_log Auto-progression engine (+reps/+weight/deload rules), 4-week schedule calendar, progress dashboard with Chart.js charts, per-exercise progress pages with suggestions.
- `config/exercises.yaml` — exercise library (name, muscle group, workout day, sets, tempo, form cues)
- `config/user_programs.yaml` — per-user week 1/4 reps and weights for each exercise
- Seed script that loads YAML into DB on first run
- Admin user auto-created from `.env` credentials on startup (password hashed with bcrypt)
- Two initial user profiles seeded: Phillip and Daughter
- Service layer for all DB access (no direct queries from routes)
### Phase 3: Workout UI ✅ ---
**Completed:** 2026-02-24 ## Future Ideas
**Summary:** Built the core user-facing experience — admin login with bcrypt + signed session cookies, profile switcher, workout day viewer with warmups and exercise cards, and HTMX-powered exercise browser with search/filter. Added Jinja2 context processor middleware for automatic nav context injection. - Multi-user login (replace profile switcher with individual logins)
- REST API for mobile clients
**Key files:** - Exercise video/image attachments
- `app/services/auth_service.py` — AuthService (bcrypt auth + itsdangerous session tokens) - Custom workout program builder
- `app/utils/auth.py``get_current_admin_user` (303 redirect to /login), `get_active_profile_id` - Export/import workout data (CSV/JSON)
- `app/routes/auth.py` — login/logout routes - Notifications/reminders
- `app/routes/profiles.py` — profile list, switch, edit routes - Social features (sharing workouts)
- `app/routes/workouts.py` — workout day list + detail viewer
- `app/routes/exercises.py` — exercise browser with HTMX search
- `app/templates/partials/nav.html` — profile switcher dropdown (reads from request.state)
- `app/main.py` — NavContextMiddleware, secret_key, all routers registered
**Endpoints created:**
- `GET /login` — render login form
- `POST /login` — authenticate and set session cookie
- `GET /logout` — clear session, redirect to /login
- `GET /profiles` — list user profiles
- `POST /profiles/switch` — set active profile cookie
- `GET /profiles/{id}/edit` — profile edit form
- `POST /profiles/{id}/edit` — update profile
- `GET /workouts` — workout day cards
- `GET /workouts/{day_name}` — warmups + exercises + programming targets
- `GET /exercises` — exercise browser with filter dropdowns
- `GET /exercises/search` — HTMX partial for filtered exercise list
**Key details:**
- Auth uses 303 redirect to /login (not 401) for browser UX
- Nav context injected via `NavContextMiddleware` into `request.state` (admin, profiles, active_profile)
- Session cookie: httponly=True, samesite="lax", max_age=86400 (24h)
- `itsdangerous>=2.2.0` added to requirements.txt
- `python-multipart>=0.0.20` added for form data parsing
- 66 tests pass (12 new Phase 3 tests + 54 existing)
## Phase 4: Logging & Tracking
Enable workout logging so users can track what they actually did.
- Per-exercise logging: sets completed, reps, weight used, "felt easy?" toggle
- Workout session model (date, user profile, workout day)
- HTMX inline logging — log directly from the workout day view
- Log history view per user profile
- Edit/delete past log entries
## Phase 5: Progression & Analytics
Smart suggestions and visual progress tracking.
- Auto-progression engine based on log history
- +1-2 reps/week, +5 lbs every 2 weeks
- Deload detection at week 5 (-20% weight)
- 4-week schedule view (calendar-style, shows which day maps to which date)
- Progress dashboard per user profile
- Per-exercise progress history and trends
- Summary stats (total volume, streak tracking)

2
run_dev_docker.sh Executable file
View File

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

View File

@@ -3,25 +3,27 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
from fastapi import HTTPException
from app.utils.auth import get_current_admin_user, get_active_profile_id from app.utils.auth import (
NotAuthenticatedError,
get_current_admin_user,
get_active_profile_id,
)
class TestAuthDependency: class TestAuthDependency:
"""Tests for the require_admin dependency.""" """Tests for the require_admin dependency."""
def test_redirects_when_no_session_cookie(self) -> None: def test_redirects_when_no_session_cookie(self) -> None:
"""Should redirect to /login (303) when no session cookie is present.""" """Should raise NotAuthenticatedError when no session cookie is present."""
request = MagicMock() request = MagicMock()
request.cookies = {} request.cookies = {}
with pytest.raises(HTTPException) as exc_info: with pytest.raises(NotAuthenticatedError):
get_current_admin_user(request=request, session=MagicMock()) get_current_admin_user(request=request, session=MagicMock())
assert exc_info.value.status_code == 303
def test_redirects_when_invalid_token(self) -> None: def test_redirects_when_invalid_token(self) -> None:
"""Should redirect to /login (303) when session cookie has invalid token.""" """Should raise NotAuthenticatedError when session cookie has invalid token."""
request = MagicMock() request = MagicMock()
request.cookies = {"session": "invalid-token"} request.cookies = {"session": "invalid-token"}
request.app.state.secret_key = "test-secret" request.app.state.secret_key = "test-secret"
@@ -29,9 +31,8 @@ class TestAuthDependency:
mock_session = MagicMock() mock_session = MagicMock()
mock_session.get.return_value = None mock_session.get.return_value = None
with pytest.raises(HTTPException) as exc_info: with pytest.raises(NotAuthenticatedError):
get_current_admin_user(request=request, session=mock_session) get_current_admin_user(request=request, session=mock_session)
assert exc_info.value.status_code == 303
class TestGetActiveProfileId: class TestGetActiveProfileId:

View File

@@ -9,7 +9,7 @@ class TestDashboard:
def test_dashboard_requires_auth(self, client: TestClient) -> None: def test_dashboard_requires_auth(self, client: TestClient) -> None:
"""GET /dashboard should require admin login.""" """GET /dashboard should require admin login."""
response = client.get("/dashboard", follow_redirects=False) response = client.get("/dashboard", follow_redirects=False)
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)
class TestExerciseProgress: class TestExerciseProgress:
@@ -18,4 +18,4 @@ class TestExerciseProgress:
def test_exercise_progress_requires_auth(self, client: TestClient) -> None: def test_exercise_progress_requires_auth(self, client: TestClient) -> None:
"""GET /dashboard/exercise/1 should require admin login.""" """GET /dashboard/exercise/1 should require admin login."""
response = client.get("/dashboard/exercise/1", follow_redirects=False) response = client.get("/dashboard/exercise/1", follow_redirects=False)
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)

View File

@@ -9,7 +9,7 @@ class TestExerciseBrowser:
def test_exercise_browser_requires_auth(self, client: TestClient) -> None: def test_exercise_browser_requires_auth(self, client: TestClient) -> None:
"""GET /exercises should require admin login.""" """GET /exercises should require admin login."""
response = client.get("/exercises", follow_redirects=False) response = client.get("/exercises", follow_redirects=False)
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)
class TestExerciseSearch: class TestExerciseSearch:
@@ -21,4 +21,4 @@ class TestExerciseSearch:
"/exercises/search?workout_day=Push", "/exercises/search?workout_day=Push",
follow_redirects=False, follow_redirects=False,
) )
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)

View File

@@ -9,7 +9,7 @@ class TestLogHistory:
def test_history_requires_auth(self, client: TestClient) -> None: def test_history_requires_auth(self, client: TestClient) -> None:
"""GET /history should require admin login.""" """GET /history should require admin login."""
response = client.get("/history", follow_redirects=False) response = client.get("/history", follow_redirects=False)
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)
class TestSessionDetail: class TestSessionDetail:
@@ -18,4 +18,4 @@ class TestSessionDetail:
def test_session_detail_requires_auth(self, client: TestClient) -> None: def test_session_detail_requires_auth(self, client: TestClient) -> None:
"""GET /history/1 should require admin login.""" """GET /history/1 should require admin login."""
response = client.get("/history/1", follow_redirects=False) response = client.get("/history/1", follow_redirects=False)
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)

View File

@@ -19,7 +19,7 @@ class TestLogSet:
}, },
follow_redirects=False, follow_redirects=False,
) )
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)
class TestLogEdit: class TestLogEdit:
@@ -32,7 +32,7 @@ class TestLogEdit:
data={"reps": "10", "weight": "35 lbs"}, data={"reps": "10", "weight": "35 lbs"},
follow_redirects=False, follow_redirects=False,
) )
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)
class TestLogDelete: class TestLogDelete:
@@ -41,4 +41,4 @@ class TestLogDelete:
def test_delete_log_requires_auth(self, client: TestClient) -> None: def test_delete_log_requires_auth(self, client: TestClient) -> None:
"""POST /log/1/delete should require admin login.""" """POST /log/1/delete should require admin login."""
response = client.post("/log/1/delete", follow_redirects=False) response = client.post("/log/1/delete", follow_redirects=False)
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)

View File

@@ -14,7 +14,7 @@ class TestProfileSwitcher:
follow_redirects=False, follow_redirects=False,
) )
# Should redirect to login or return 401 # Should redirect to login or return 401
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)
class TestProfileList: class TestProfileList:
@@ -23,4 +23,4 @@ class TestProfileList:
def test_profiles_page_requires_auth(self, client: TestClient) -> None: def test_profiles_page_requires_auth(self, client: TestClient) -> None:
"""GET /profiles should require admin login.""" """GET /profiles should require admin login."""
response = client.get("/profiles", follow_redirects=False) response = client.get("/profiles", follow_redirects=False)
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)

View File

@@ -9,4 +9,4 @@ class TestSchedule:
def test_schedule_requires_auth(self, client: TestClient) -> None: def test_schedule_requires_auth(self, client: TestClient) -> None:
"""GET /schedule should require admin login.""" """GET /schedule should require admin login."""
response = client.get("/schedule", follow_redirects=False) response = client.get("/schedule", follow_redirects=False)
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)

View File

@@ -9,9 +9,9 @@ class TestWorkoutDayViewer:
def test_workout_day_requires_auth(self, client: TestClient) -> None: def test_workout_day_requires_auth(self, client: TestClient) -> None:
"""GET /workouts/push should require admin login.""" """GET /workouts/push should require admin login."""
response = client.get("/workouts/push", follow_redirects=False) response = client.get("/workouts/push", follow_redirects=False)
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)
def test_workout_days_list_requires_auth(self, client: TestClient) -> None: def test_workout_days_list_requires_auth(self, client: TestClient) -> None:
"""GET /workouts should require admin login.""" """GET /workouts should require admin login."""
response = client.get("/workouts", follow_redirects=False) response = client.get("/workouts", follow_redirects=False)
assert response.status_code in (401, 303) assert response.status_code in (401, 302, 303)

8
uv.lock generated Normal file
View File

@@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "sneakyswole"
version = "0.1.0"
source = { editable = "." }

Binary file not shown.