Compare commits
13 Commits
fd979aa2da
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
| 2208f0492b | |||
| 7b535bef6e | |||
| 272563060c | |||
| 3dc0171639 | |||
| 312b14e57b | |||
| d8b52cf907 | |||
| b18146e96c | |||
| ee45513f30 | |||
| d90c9faf23 | |||
| 093f7aa55e | |||
| 77d1bc4a25 | |||
| 89d70fcae7 | |||
| 53e62f694f |
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
12
.env.production
Normal 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
|
||||||
46
.gitea/workflows/build-package.yaml
Normal file
46
.gitea/workflows/build-package.yaml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: docker.io/catthehacker/ubuntu:act-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.sneakygeek.net
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.sneakygeek.net/sneakygeek/sneakyswole
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=sha,prefix=sha-,format=short
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.sneakygeek.net/sneakygeek/sneakyswole:buildcache
|
||||||
|
cache-to: type=registry,ref=git.sneakygeek.net/sneakygeek/sneakyswole:buildcache,mode=max
|
||||||
@@ -14,6 +14,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("")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
29
docker-compose.dev.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: sneakyswole-dev
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-8000}:8000"
|
||||||
|
volumes:
|
||||||
|
- sneakyswole-data:/app/data
|
||||||
|
- ./app:/app/app
|
||||||
|
- ./config:/app/config
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=development
|
||||||
|
- APP_LOG_LEVEL=debug
|
||||||
|
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sneakyswole-data:
|
||||||
|
driver: local
|
||||||
@@ -1,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
278
docs/API_REFERENCE.md
Normal 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`
|
||||||
@@ -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
197
docs/architecture.md
Normal 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
202
docs/database_schema.md
Normal 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.
|
||||||
@@ -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
|
|
||||||
@@ -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
2
run_dev_docker.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
docker compose -f docker-compose.dev.yaml up --build "$@"
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
8
uv.lock
generated
Normal 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.
Reference in New Issue
Block a user