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.
228 lines
6.8 KiB
Python
228 lines
6.8 KiB
Python
"""Service layer for workout log (set-level) data access.
|
|
|
|
Handles CRUD for individual set logs within workout sessions.
|
|
"""
|
|
|
|
from typing import Optional
|
|
|
|
import structlog
|
|
from sqlmodel import Session, select
|
|
|
|
from app.models.workout_log import WorkoutLog
|
|
from app.models.workout_session import WorkoutSession
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
class LogService:
|
|
"""Handles CRUD operations for WorkoutLog records.
|
|
|
|
Args:
|
|
session: An active SQLModel Session.
|
|
"""
|
|
|
|
def __init__(self, session: Session) -> None:
|
|
self._session = session
|
|
|
|
def create_log(
|
|
self,
|
|
session_id: int,
|
|
exercise_id: int,
|
|
set_number: int,
|
|
reps_completed: int,
|
|
weight_used: str,
|
|
felt_easy: bool,
|
|
notes: Optional[str] = None,
|
|
) -> WorkoutLog:
|
|
"""Create a new set log entry.
|
|
|
|
Args:
|
|
session_id: FK to workout_sessions.
|
|
exercise_id: FK to exercises.
|
|
set_number: Which set (1, 2, 3...).
|
|
reps_completed: Actual reps performed.
|
|
weight_used: Weight as string (e.g., "30 lbs").
|
|
felt_easy: Whether the set felt easy.
|
|
notes: Optional notes for this set.
|
|
|
|
Returns:
|
|
The newly created WorkoutLog record.
|
|
"""
|
|
log = WorkoutLog(
|
|
session_id=session_id,
|
|
exercise_id=exercise_id,
|
|
set_number=set_number,
|
|
reps_completed=reps_completed,
|
|
weight_used=weight_used,
|
|
felt_easy=felt_easy,
|
|
notes=notes,
|
|
)
|
|
self._session.add(log)
|
|
self._session.commit()
|
|
self._session.refresh(log)
|
|
logger.info(
|
|
"log_created",
|
|
session_id=session_id,
|
|
exercise_id=exercise_id,
|
|
set=set_number,
|
|
)
|
|
return log
|
|
|
|
def list_logs_for_session(self, session_id: int) -> list[WorkoutLog]:
|
|
"""List all log entries for a workout session.
|
|
|
|
Args:
|
|
session_id: The workout session ID.
|
|
|
|
Returns:
|
|
List of WorkoutLog records ordered by exercise and set number.
|
|
"""
|
|
statement = (
|
|
select(WorkoutLog)
|
|
.where(WorkoutLog.session_id == session_id)
|
|
.order_by(WorkoutLog.exercise_id, WorkoutLog.set_number)
|
|
)
|
|
return list(self._session.exec(statement).all())
|
|
|
|
def list_logs_for_exercise(
|
|
self,
|
|
session_id: int,
|
|
exercise_id: int,
|
|
) -> list[WorkoutLog]:
|
|
"""List log entries for a specific exercise within a session.
|
|
|
|
Args:
|
|
session_id: The workout session ID.
|
|
exercise_id: The exercise ID.
|
|
|
|
Returns:
|
|
List of WorkoutLog records for this exercise, ordered by set.
|
|
"""
|
|
statement = (
|
|
select(WorkoutLog)
|
|
.where(
|
|
WorkoutLog.session_id == session_id,
|
|
WorkoutLog.exercise_id == exercise_id,
|
|
)
|
|
.order_by(WorkoutLog.set_number)
|
|
)
|
|
return list(self._session.exec(statement).all())
|
|
|
|
def get_log_by_id(self, log_id: int) -> Optional[WorkoutLog]:
|
|
"""Retrieve a log entry by primary key.
|
|
|
|
Args:
|
|
log_id: The log entry ID.
|
|
|
|
Returns:
|
|
The WorkoutLog record, or None if not found.
|
|
"""
|
|
return self._session.get(WorkoutLog, log_id)
|
|
|
|
def update_log(self, log_id: int, **kwargs) -> WorkoutLog:
|
|
"""Update fields on an existing log entry.
|
|
|
|
Args:
|
|
log_id: The log entry ID.
|
|
**kwargs: Field names and new values.
|
|
|
|
Returns:
|
|
The updated WorkoutLog record.
|
|
|
|
Raises:
|
|
ValueError: If the log is not found.
|
|
"""
|
|
log = self.get_log_by_id(log_id)
|
|
if log is None:
|
|
raise ValueError(f"WorkoutLog with id {log_id} not found")
|
|
|
|
for key, value in kwargs.items():
|
|
if hasattr(log, key):
|
|
setattr(log, key, value)
|
|
|
|
self._session.add(log)
|
|
self._session.commit()
|
|
self._session.refresh(log)
|
|
logger.info("log_updated", log_id=log_id, fields=list(kwargs.keys()))
|
|
return log
|
|
|
|
def delete_log(self, log_id: int) -> None:
|
|
"""Delete a log entry.
|
|
|
|
Removes the log, renumbers remaining sets, and cleans up the
|
|
parent session if no logs remain.
|
|
|
|
Args:
|
|
log_id: The log entry ID.
|
|
|
|
Raises:
|
|
ValueError: If the log is not found.
|
|
"""
|
|
log = self.get_log_by_id(log_id)
|
|
if log is None:
|
|
raise ValueError(f"WorkoutLog with id {log_id} not found")
|
|
|
|
session_id = log.session_id
|
|
exercise_id = log.exercise_id
|
|
self._session.delete(log)
|
|
self._session.commit()
|
|
logger.info("log_deleted", log_id=log_id)
|
|
|
|
# Renumber remaining sets so they stay sequential (1, 2, 3...)
|
|
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)
|
|
).first()
|
|
if any_remaining is None:
|
|
ws = self._session.get(WorkoutSession, session_id)
|
|
if ws:
|
|
self._session.delete(ws)
|
|
self._session.commit()
|
|
logger.info("session_deleted_empty", session_id=session_id)
|
|
|
|
def get_latest_logs_for_exercise(
|
|
self,
|
|
user_id: int,
|
|
exercise_id: int,
|
|
limit: int = 10,
|
|
) -> list[WorkoutLog]:
|
|
"""Get the most recent log entries for an exercise across sessions.
|
|
|
|
Used by the progression engine (Phase 5) to determine
|
|
what the user last did for this exercise.
|
|
|
|
Args:
|
|
user_id: The user's ID.
|
|
exercise_id: The exercise ID.
|
|
limit: Maximum number of logs to return.
|
|
|
|
Returns:
|
|
List of recent WorkoutLog records, newest first.
|
|
"""
|
|
statement = (
|
|
select(WorkoutLog)
|
|
.join(WorkoutSession, WorkoutLog.session_id == WorkoutSession.id)
|
|
.where(
|
|
WorkoutSession.user_id == user_id,
|
|
WorkoutLog.exercise_id == exercise_id,
|
|
)
|
|
.order_by(WorkoutSession.date.desc(), WorkoutLog.set_number)
|
|
.limit(limit)
|
|
)
|
|
return list(self._session.exec(statement).all())
|