"""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())