Compare commits

...

10 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
18 changed files with 205 additions and 29 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:

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)