Compare commits

..

3 Commits

Author SHA1 Message Date
60acdbefdb Merge pull request 'feature/auto-populate-suggestions' (#1) from feature/auto-populate-suggestions into master
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11s
Reviewed-on: #1
2026-02-24 21:49:05 +00: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
11 changed files with 54 additions and 22 deletions

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
@@ -80,6 +81,10 @@ async def log_set(
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
# Fetch suggestion for pre-filling the next set form
progression = ProgressionService(session)
suggestion = progression.get_suggestion(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,
@@ -88,6 +93,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": suggestion.get("suggested_reps"),
"suggested_weight": suggestion.get("suggested_weight"),
}) })
@@ -124,6 +131,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
# Fetch suggestion for pre-filling the next set form
active_profile_id = get_active_profile_id(request)
suggestion = {}
if active_profile_id:
progression = ProgressionService(session)
suggestion = progression.get_suggestion(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 +146,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": suggestion.get("suggested_reps"),
"suggested_weight": suggestion.get("suggested_weight"),
}) })
@@ -164,6 +180,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
# Fetch suggestion for pre-filling the next set form
active_profile_id = get_active_profile_id(request)
suggestion = {}
if active_profile_id:
progression = ProgressionService(session)
suggestion = progression.get_suggestion(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 +195,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": suggestion.get("suggested_reps"),
"suggested_weight": suggestion.get("suggested_weight"),
}) })
return HTMLResponse("") return HTMLResponse("")

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;">

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)