Additional fixes from parallel hardening streams: - exercise/serializers: remove unused WorkoutEquipment import, add prefetch docs - generator/serializers: N+1 fix in GeneratedWorkoutDetailSerializer (inline workout dict, prefetch-aware supersets) - generator/services/plan_builder: eliminate redundant .save() after .create() via single create_kwargs dict - generator/services/workout_generator: proper type-match validation for HIIT/cardio/core/flexibility; fix diversify type count to account for removed entry - generator/views: request-level caching for get_registered_user helper; prefetch chain for accept_workout - superset/serializers: guard against dangling FK in SupersetExerciseSerializer - workout/helpers: use prefetched data instead of re-querying per superset Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1214 lines
43 KiB
Python
1214 lines
43 KiB
Python
import logging
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
|
|
from django.db import transaction
|
|
from django.shortcuts import get_object_or_404
|
|
from rest_framework.decorators import (
|
|
api_view,
|
|
authentication_classes,
|
|
permission_classes,
|
|
)
|
|
from rest_framework.authentication import TokenAuthentication
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework import status
|
|
|
|
from django.core.cache import cache
|
|
|
|
from registered_user.models import RegisteredUser
|
|
from muscle.models import Muscle, ExerciseMuscle
|
|
from equipment.models import Equipment
|
|
from exercise.models import Exercise
|
|
from workout.models import Workout, PlannedWorkout
|
|
from superset.models import Superset, SupersetExercise
|
|
|
|
from .models import (
|
|
WorkoutType,
|
|
UserPreference,
|
|
GeneratedWeeklyPlan,
|
|
GeneratedWorkout,
|
|
MuscleGroupSplit,
|
|
WeeklySplitPattern,
|
|
WorkoutStructureRule,
|
|
MovementPatternOrder,
|
|
)
|
|
from .serializers import (
|
|
WorkoutTypeSerializer,
|
|
UserPreferenceSerializer,
|
|
UserPreferenceUpdateSerializer,
|
|
GeneratedWeeklyPlanSerializer,
|
|
GeneratedWorkoutSerializer,
|
|
GeneratedWorkoutDetailSerializer,
|
|
MuscleSerializer,
|
|
EquipmentSerializer,
|
|
MuscleGroupSplitSerializer,
|
|
)
|
|
from exercise.serializers import ExerciseSerializer
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_registered_user(request):
|
|
"""Get RegisteredUser for the authenticated user, or 404.
|
|
|
|
Caches the result on the request object to avoid repeated DB hits
|
|
when called multiple times in the same request cycle.
|
|
"""
|
|
if not hasattr(request, '_registered_user'):
|
|
request._registered_user = get_object_or_404(RegisteredUser, user=request.user)
|
|
return request._registered_user
|
|
|
|
|
|
# ============================================================
|
|
# Generation Rules
|
|
# ============================================================
|
|
|
|
@api_view(['GET'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def generation_rules(request):
|
|
"""Return the generation guardrail rules as JSON."""
|
|
from generator.services.workout_generator import GENERATION_RULES
|
|
return Response(GENERATION_RULES, status=status.HTTP_200_OK)
|
|
|
|
|
|
# ============================================================
|
|
# Preferences
|
|
# ============================================================
|
|
|
|
@api_view(['GET'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def get_preferences(request):
|
|
"""Get (or auto-create) the UserPreference for the logged-in user."""
|
|
registered_user = get_registered_user(request)
|
|
preference, _created = UserPreference.objects.get_or_create(
|
|
registered_user=registered_user,
|
|
)
|
|
serializer = UserPreferenceSerializer(preference)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
@api_view(['PUT'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def update_preferences(request):
|
|
"""Update the logged-in user's preferences. Accepts equipment_ids, muscle_ids, workout_type_ids."""
|
|
registered_user = get_registered_user(request)
|
|
preference, _created = UserPreference.objects.get_or_create(
|
|
registered_user=registered_user,
|
|
)
|
|
serializer = UserPreferenceUpdateSerializer(preference, data=request.data, partial=True)
|
|
if serializer.is_valid():
|
|
warnings = serializer.validated_data.get('_validation_warnings', [])
|
|
serializer.save()
|
|
# Return the full read serializer so the client sees nested objects
|
|
read_serializer = UserPreferenceSerializer(preference)
|
|
response_data = read_serializer.data
|
|
if warnings:
|
|
response_data['warnings'] = warnings
|
|
return Response(response_data, status=status.HTTP_200_OK)
|
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
|
|
# ============================================================
|
|
# Plan Generation
|
|
# ============================================================
|
|
|
|
@api_view(['POST'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def generate_plan(request):
|
|
"""
|
|
Generate a weekly workout plan.
|
|
Body: {"week_start_date": "YYYY-MM-DD"}
|
|
"""
|
|
registered_user = get_registered_user(request)
|
|
|
|
week_start_date_str = request.data.get('week_start_date')
|
|
if not week_start_date_str:
|
|
return Response(
|
|
{'error': 'week_start_date is required (YYYY-MM-DD).'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
week_start_date = datetime.strptime(week_start_date_str, '%Y-%m-%d').date()
|
|
except (ValueError, TypeError):
|
|
return Response(
|
|
{'error': 'Invalid date format. Use YYYY-MM-DD.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Ensure the user has preferences set up
|
|
preference = UserPreference.objects.filter(registered_user=registered_user).first()
|
|
if not preference:
|
|
return Response(
|
|
{'error': 'User preferences not found. Please set up your preferences first.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Optional overrides
|
|
duration_minutes = request.data.get('duration_minutes')
|
|
rest_day_indices = request.data.get('rest_day_indices')
|
|
day_workout_types = request.data.get('day_workout_types')
|
|
|
|
if duration_minutes is not None:
|
|
try:
|
|
duration_minutes = int(duration_minutes)
|
|
if duration_minutes < 15 or duration_minutes > 120:
|
|
return Response(
|
|
{'error': 'duration_minutes must be between 15 and 120.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
except (ValueError, TypeError):
|
|
return Response(
|
|
{'error': 'duration_minutes must be an integer.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if rest_day_indices is not None:
|
|
if not isinstance(rest_day_indices, list):
|
|
return Response(
|
|
{'error': 'rest_day_indices must be a list of integers (0-6).'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if any(not isinstance(i, int) or i < 0 or i > 6 for i in rest_day_indices):
|
|
return Response(
|
|
{'error': 'rest_day_indices values must be integers 0-6.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if day_workout_types is not None:
|
|
if not isinstance(day_workout_types, dict):
|
|
return Response(
|
|
{'error': 'day_workout_types must be a dict of {day_index: workout_type_id}.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
from generator.services.workout_generator import WorkoutGenerator
|
|
|
|
start_time = time.time()
|
|
generator = WorkoutGenerator(
|
|
preference,
|
|
duration_override=duration_minutes,
|
|
rest_day_indices=rest_day_indices,
|
|
day_workout_type_overrides=day_workout_types,
|
|
)
|
|
plan = generator.generate_weekly_plan(week_start_date)
|
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
|
|
|
plan.generation_time_ms = elapsed_ms
|
|
plan.save()
|
|
|
|
generation_warnings = generator.warnings
|
|
|
|
except Exception as e:
|
|
logger.exception("Unexpected error in generate_plan")
|
|
return Response(
|
|
{"error": "An unexpected error occurred. Please try again."},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
serializer = GeneratedWeeklyPlanSerializer(plan)
|
|
response_data = serializer.data
|
|
if generation_warnings:
|
|
response_data['warnings'] = list(dict.fromkeys(generation_warnings))
|
|
return Response(response_data, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
# ============================================================
|
|
# Plan Listing / Detail
|
|
# ============================================================
|
|
|
|
@api_view(['GET'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def list_plans(request):
|
|
"""List all generated plans for the logged-in user, newest first."""
|
|
registered_user = get_registered_user(request)
|
|
plans = GeneratedWeeklyPlan.objects.filter(
|
|
registered_user=registered_user,
|
|
).select_related(
|
|
'registered_user',
|
|
).prefetch_related(
|
|
'generated_workouts__workout_type',
|
|
'generated_workouts__workout',
|
|
)
|
|
serializer = GeneratedWeeklyPlanSerializer(plans, many=True)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
@api_view(['GET'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def plan_detail(request, plan_id):
|
|
"""Get a single plan with all its generated workouts."""
|
|
registered_user = get_registered_user(request)
|
|
plan = get_object_or_404(
|
|
GeneratedWeeklyPlan.objects.select_related(
|
|
'registered_user',
|
|
).prefetch_related(
|
|
'generated_workouts__workout_type',
|
|
'generated_workouts__workout',
|
|
),
|
|
pk=plan_id,
|
|
registered_user=registered_user,
|
|
)
|
|
serializer = GeneratedWeeklyPlanSerializer(plan)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
# ============================================================
|
|
# Workout Actions (accept / reject / rate / regenerate)
|
|
# ============================================================
|
|
|
|
@api_view(['POST'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def accept_workout(request, workout_id):
|
|
"""
|
|
Accept a generated workout.
|
|
Sets status to 'accepted' and creates a PlannedWorkout for the scheduled_date.
|
|
"""
|
|
registered_user = get_registered_user(request)
|
|
generated_workout = get_object_or_404(
|
|
GeneratedWorkout.objects.select_related('workout', 'workout_type').prefetch_related(
|
|
'workout__superset_workout__superset_exercises__exercise',
|
|
),
|
|
pk=workout_id,
|
|
plan__registered_user=registered_user,
|
|
)
|
|
|
|
if generated_workout.is_rest_day:
|
|
return Response(
|
|
{'error': 'Cannot accept a rest day.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if not generated_workout.workout:
|
|
return Response(
|
|
{'error': 'No workout linked to this generated workout.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
generated_workout.status = 'accepted'
|
|
generated_workout.save()
|
|
|
|
if generated_workout.workout:
|
|
cache.delete(f"wk{generated_workout.workout.id}")
|
|
cache.delete(f"plan{generated_workout.plan_id}")
|
|
|
|
# Create a PlannedWorkout so it appears on the user's calendar
|
|
PlannedWorkout.objects.get_or_create(
|
|
workout=generated_workout.workout,
|
|
registered_user=registered_user,
|
|
on_date=generated_workout.scheduled_date,
|
|
)
|
|
|
|
serializer = GeneratedWorkoutDetailSerializer(generated_workout)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
@api_view(['POST'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def reject_workout(request, workout_id):
|
|
"""
|
|
Reject a generated workout with optional feedback.
|
|
Body: {"feedback": "..."}
|
|
"""
|
|
registered_user = get_registered_user(request)
|
|
generated_workout = get_object_or_404(
|
|
GeneratedWorkout.objects.select_related('workout', 'workout_type'),
|
|
pk=workout_id,
|
|
plan__registered_user=registered_user,
|
|
)
|
|
|
|
generated_workout.status = 'rejected'
|
|
feedback = request.data.get('feedback', '')
|
|
if feedback:
|
|
generated_workout.user_feedback = feedback
|
|
generated_workout.save()
|
|
|
|
# Invalidate caches
|
|
if generated_workout.workout:
|
|
cache.delete(f"wk{generated_workout.workout.id}")
|
|
cache.delete(f"plan{generated_workout.plan_id}")
|
|
|
|
serializer = GeneratedWorkoutSerializer(generated_workout)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
@api_view(['POST'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def rate_workout(request, workout_id):
|
|
"""
|
|
Rate a generated workout 1-5 with optional feedback.
|
|
Body: {"rating": 5, "feedback": "..."}
|
|
"""
|
|
registered_user = get_registered_user(request)
|
|
generated_workout = get_object_or_404(
|
|
GeneratedWorkout.objects.select_related('workout', 'workout_type'),
|
|
pk=workout_id,
|
|
plan__registered_user=registered_user,
|
|
)
|
|
|
|
rating = request.data.get('rating')
|
|
if rating is None:
|
|
return Response(
|
|
{'error': 'rating is required (1-5).'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
rating = int(rating)
|
|
except (ValueError, TypeError):
|
|
return Response(
|
|
{'error': 'rating must be an integer between 1 and 5.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if rating < 1 or rating > 5:
|
|
return Response(
|
|
{'error': 'rating must be between 1 and 5.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
generated_workout.user_rating = rating
|
|
feedback = request.data.get('feedback', '')
|
|
if feedback:
|
|
generated_workout.user_feedback = feedback
|
|
generated_workout.save()
|
|
|
|
# Invalidate caches
|
|
if generated_workout.workout:
|
|
cache.delete(f"wk{generated_workout.workout.id}")
|
|
cache.delete(f"plan{generated_workout.plan_id}")
|
|
|
|
serializer = GeneratedWorkoutSerializer(generated_workout)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
@api_view(['POST'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def regenerate_workout(request, workout_id):
|
|
"""
|
|
Regenerate a single workout within an existing plan.
|
|
Deletes the old linked Workout (if any) and generates a fresh one for the same day/type.
|
|
"""
|
|
registered_user = get_registered_user(request)
|
|
generated_workout = get_object_or_404(
|
|
GeneratedWorkout.objects.select_related('workout', 'workout_type', 'plan'),
|
|
pk=workout_id,
|
|
plan__registered_user=registered_user,
|
|
)
|
|
|
|
if generated_workout.is_rest_day:
|
|
return Response(
|
|
{'error': 'Cannot regenerate a rest day.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
preference = UserPreference.objects.filter(registered_user=registered_user).first()
|
|
if not preference:
|
|
return Response(
|
|
{'error': 'User preferences not found.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Delete old linked Workout (cascades to Supersets and SupersetExercises)
|
|
old_workout = generated_workout.workout
|
|
if old_workout:
|
|
cache.delete(f"wk{old_workout.id}")
|
|
generated_workout.workout = None
|
|
generated_workout.save()
|
|
old_workout.delete()
|
|
|
|
try:
|
|
from generator.services.workout_generator import WorkoutGenerator
|
|
from generator.services.plan_builder import PlanBuilder
|
|
|
|
generator = WorkoutGenerator(preference)
|
|
|
|
# Exclude exercises from sibling workouts in the same plan (single query)
|
|
sibling_exercise_ids = set(
|
|
SupersetExercise.objects.filter(
|
|
superset__workout__generated_from__plan=generated_workout.plan,
|
|
superset__workout__generated_from__is_rest_day=False,
|
|
).exclude(
|
|
superset__workout__generated_from=generated_workout,
|
|
).values_list('exercise_id', flat=True)
|
|
)
|
|
if sibling_exercise_ids:
|
|
generator.exercise_selector.hard_exclude_ids.update(sibling_exercise_ids)
|
|
|
|
# Build a muscle_split dict from the stored target_muscles
|
|
target_muscles = generated_workout.target_muscles or []
|
|
|
|
# Infer split_type from target muscles instead of defaulting to full_body
|
|
from generator.services.muscle_normalizer import classify_split_type
|
|
inferred_split = classify_split_type(set(target_muscles)) if target_muscles else 'full_body'
|
|
|
|
muscle_split = {
|
|
'label': generated_workout.focus_area or 'Workout',
|
|
'muscles': target_muscles,
|
|
'split_type': inferred_split,
|
|
}
|
|
|
|
# Refine from MuscleGroupSplit if exact match exists
|
|
if target_muscles:
|
|
mgs = MuscleGroupSplit.objects.filter(
|
|
muscle_names=target_muscles
|
|
).first()
|
|
if mgs:
|
|
muscle_split['split_type'] = mgs.split_type
|
|
|
|
# Infer workout type from split affinity when not stored
|
|
workout_type = generated_workout.workout_type
|
|
if workout_type is None:
|
|
from generator.services.workout_generator import SPLIT_TYPE_WORKOUT_AFFINITY
|
|
preferred_types = list(preference.preferred_workout_types.all())
|
|
|
|
# Try to match via split type affinity
|
|
if preferred_types and inferred_split:
|
|
affinity_names = SPLIT_TYPE_WORKOUT_AFFINITY.get(inferred_split, set())
|
|
for wt in preferred_types:
|
|
if wt.name.strip().lower() in affinity_names:
|
|
workout_type = wt
|
|
break
|
|
|
|
# Fall back to first preferred type, then functional_strength_training
|
|
if workout_type is None and preferred_types:
|
|
workout_type = preferred_types[0]
|
|
if workout_type is None:
|
|
workout_type = WorkoutType.objects.filter(
|
|
name='functional_strength_training'
|
|
).first() or WorkoutType.objects.first()
|
|
|
|
workout_spec = generator.generate_single_workout(
|
|
muscle_split=muscle_split,
|
|
workout_type=workout_type,
|
|
scheduled_date=generated_workout.scheduled_date,
|
|
)
|
|
|
|
# Create the Workout object and link it to the GeneratedWorkout
|
|
plan_builder = PlanBuilder(registered_user)
|
|
new_workout = plan_builder.create_workout_from_spec(workout_spec)
|
|
generated_workout.workout = new_workout
|
|
generated_workout.status = 'pending'
|
|
generated_workout.save()
|
|
cache.delete(f"wk{new_workout.id}")
|
|
cache.delete(f"plan{generated_workout.plan_id}")
|
|
|
|
except Exception as e:
|
|
logger.exception("Unexpected error in regenerate_workout")
|
|
return Response(
|
|
{"error": "An unexpected error occurred. Please try again."},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
serializer = GeneratedWorkoutDetailSerializer(generated_workout)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
# ============================================================
|
|
# Edit Endpoints (delete day / superset / exercise, swap exercise)
|
|
# ============================================================
|
|
|
|
@api_view(['DELETE'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def delete_workout_day(request, workout_id):
|
|
"""
|
|
Delete a generated workout day (converts it to a rest day).
|
|
Deletes the linked Workout object (cascading to supersets/exercises).
|
|
"""
|
|
registered_user = get_registered_user(request)
|
|
generated_workout = get_object_or_404(
|
|
GeneratedWorkout.objects.select_related('workout'),
|
|
pk=workout_id,
|
|
plan__registered_user=registered_user,
|
|
)
|
|
|
|
if generated_workout.is_rest_day:
|
|
return Response(
|
|
{'error': 'Already a rest day.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Delete linked Workout (cascades to Supersets and SupersetExercises)
|
|
old_workout = generated_workout.workout
|
|
if old_workout:
|
|
cache.delete(f"wk{old_workout.id}")
|
|
generated_workout.workout = None
|
|
generated_workout.save()
|
|
old_workout.delete()
|
|
|
|
generated_workout.is_rest_day = True
|
|
generated_workout.status = 'pending'
|
|
generated_workout.save()
|
|
cache.delete(f"plan{generated_workout.plan_id}")
|
|
|
|
serializer = GeneratedWorkoutSerializer(generated_workout)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
@api_view(['DELETE'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def delete_superset(request, superset_id):
|
|
"""Delete a superset from a workout. Re-orders remaining supersets."""
|
|
registered_user = get_registered_user(request)
|
|
superset = get_object_or_404(Superset, pk=superset_id)
|
|
|
|
# Verify ownership through the workout
|
|
workout = superset.workout
|
|
if not GeneratedWorkout.objects.filter(
|
|
workout=workout,
|
|
plan__registered_user=registered_user,
|
|
).exists():
|
|
return Response(
|
|
{'error': 'Not found.'},
|
|
status=status.HTTP_404_NOT_FOUND,
|
|
)
|
|
|
|
deleted_order = superset.order
|
|
superset.delete()
|
|
|
|
# Invalidate workout detail cache
|
|
cache.delete(f"wk{workout.id}")
|
|
|
|
# Re-order remaining supersets with bulk_update
|
|
remaining = list(
|
|
Superset.objects.filter(workout=workout, order__gt=deleted_order).order_by('order')
|
|
)
|
|
for ss in remaining:
|
|
ss.order -= 1
|
|
if remaining:
|
|
Superset.objects.bulk_update(remaining, ['order'])
|
|
|
|
return Response({'status': 'deleted'}, status=status.HTTP_200_OK)
|
|
|
|
|
|
@api_view(['DELETE'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def delete_superset_exercise(request, exercise_id):
|
|
"""Delete an exercise from a superset. Re-orders remaining exercises."""
|
|
registered_user = get_registered_user(request)
|
|
superset_exercise = get_object_or_404(SupersetExercise, pk=exercise_id)
|
|
|
|
# Verify ownership
|
|
workout = superset_exercise.superset.workout
|
|
if not GeneratedWorkout.objects.filter(
|
|
workout=workout,
|
|
plan__registered_user=registered_user,
|
|
).exists():
|
|
return Response(
|
|
{'error': 'Not found.'},
|
|
status=status.HTTP_404_NOT_FOUND,
|
|
)
|
|
|
|
superset = superset_exercise.superset
|
|
deleted_order = superset_exercise.order
|
|
superset_exercise.delete()
|
|
|
|
# Invalidate workout detail cache
|
|
cache.delete(f"wk{workout.id}")
|
|
|
|
# Re-order remaining exercises with bulk_update
|
|
remaining = list(
|
|
SupersetExercise.objects.filter(superset=superset, order__gt=deleted_order).order_by('order')
|
|
)
|
|
for se in remaining:
|
|
se.order -= 1
|
|
if remaining:
|
|
SupersetExercise.objects.bulk_update(remaining, ['order'])
|
|
|
|
# If the superset is now empty, delete it too
|
|
if SupersetExercise.objects.filter(superset=superset).count() == 0:
|
|
superset.delete()
|
|
|
|
return Response({'status': 'deleted'}, status=status.HTTP_200_OK)
|
|
|
|
|
|
@api_view(['GET'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def similar_exercises(request, exercise_id):
|
|
"""
|
|
Get exercises that share muscles with the given exercise.
|
|
Returns up to 20 alternatives sorted by muscle overlap.
|
|
"""
|
|
exercise = get_object_or_404(Exercise, pk=exercise_id)
|
|
|
|
# Get all muscle IDs for this exercise
|
|
muscle_ids = list(
|
|
ExerciseMuscle.objects.filter(exercise=exercise).values_list('muscle_id', flat=True)
|
|
)
|
|
|
|
if not muscle_ids:
|
|
return Response([], status=status.HTTP_200_OK)
|
|
|
|
# Find exercises that share at least one muscle, excluding the original
|
|
from django.db.models import Count
|
|
similar = (
|
|
Exercise.objects
|
|
.filter(exercise_muscle_exercise__muscle_id__in=muscle_ids)
|
|
.exclude(pk=exercise.pk)
|
|
.annotate(muscle_overlap=Count('exercise_muscle_exercise'))
|
|
.order_by('-muscle_overlap', 'name')[:20]
|
|
)
|
|
|
|
data = ExerciseSerializer(similar, many=True).data
|
|
return Response(data, status=status.HTTP_200_OK)
|
|
|
|
|
|
@api_view(['POST'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def swap_exercise(request, exercise_id):
|
|
"""
|
|
Swap a SupersetExercise's exercise for a new one.
|
|
Body: {"new_exercise_id": 123}
|
|
"""
|
|
registered_user = get_registered_user(request)
|
|
superset_exercise = get_object_or_404(SupersetExercise, pk=exercise_id)
|
|
|
|
# Verify ownership
|
|
workout = superset_exercise.superset.workout
|
|
if not GeneratedWorkout.objects.filter(
|
|
workout=workout,
|
|
plan__registered_user=registered_user,
|
|
).exists():
|
|
return Response(
|
|
{'error': 'Not found.'},
|
|
status=status.HTTP_404_NOT_FOUND,
|
|
)
|
|
|
|
new_exercise_id = request.data.get('new_exercise_id')
|
|
if not new_exercise_id:
|
|
return Response(
|
|
{'error': 'new_exercise_id is required.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
new_exercise = get_object_or_404(Exercise, pk=new_exercise_id)
|
|
superset_exercise.exercise = new_exercise
|
|
superset_exercise.save()
|
|
|
|
# Invalidate workout detail cache
|
|
cache.delete(f"wk{workout.id}")
|
|
|
|
from superset.serializers import SupersetExerciseSerializer
|
|
serializer = SupersetExerciseSerializer(superset_exercise)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
# ============================================================
|
|
# Reference Endpoints (for preference UI)
|
|
# ============================================================
|
|
|
|
@api_view(['GET'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def list_muscles(request):
|
|
"""List all muscles (for preference selection UI)."""
|
|
muscles = Muscle.objects.all().order_by('name')
|
|
serializer = MuscleSerializer(muscles, many=True)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
@api_view(['GET'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def list_equipment(request):
|
|
"""List all equipment (for preference selection UI)."""
|
|
equipment = Equipment.objects.all().order_by('category', 'name')
|
|
serializer = EquipmentSerializer(equipment, many=True)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
@api_view(['GET'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def list_workout_types(request):
|
|
"""List all available workout types."""
|
|
workout_types = WorkoutType.objects.all().order_by('name')
|
|
serializer = WorkoutTypeSerializer(workout_types, many=True)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
# ============================================================
|
|
# Analysis Stats
|
|
# ============================================================
|
|
|
|
@api_view(['GET'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def analysis_stats(request):
|
|
"""
|
|
Return counts and summaries of ML analysis data
|
|
(MuscleGroupSplits, WeeklySplitPatterns, WorkoutStructureRules, MovementPatternOrders).
|
|
"""
|
|
muscle_splits = MuscleGroupSplit.objects.all()
|
|
weekly_patterns = WeeklySplitPattern.objects.all()
|
|
structure_rules = WorkoutStructureRule.objects.select_related('workout_type').all()
|
|
movement_orders = MovementPatternOrder.objects.all()
|
|
|
|
data = {
|
|
'muscle_group_splits': {
|
|
'count': muscle_splits.count(),
|
|
'items': MuscleGroupSplitSerializer(muscle_splits, many=True).data,
|
|
},
|
|
'weekly_split_patterns': {
|
|
'count': weekly_patterns.count(),
|
|
'items': list(weekly_patterns.values(
|
|
'id', 'days_per_week', 'pattern_labels', 'frequency', 'rest_day_positions',
|
|
)),
|
|
},
|
|
'workout_structure_rules': {
|
|
'count': structure_rules.count(),
|
|
'items': list(structure_rules.values(
|
|
'id', 'workout_type__name', 'section_type', 'goal_type',
|
|
'typical_rounds', 'typical_exercises_per_superset',
|
|
'typical_rep_range_min', 'typical_rep_range_max',
|
|
)),
|
|
},
|
|
'movement_pattern_orders': {
|
|
'count': movement_orders.count(),
|
|
'items': list(movement_orders.values(
|
|
'id', 'position', 'movement_pattern', 'frequency', 'section_type',
|
|
)),
|
|
},
|
|
}
|
|
|
|
return Response(data, status=status.HTTP_200_OK)
|
|
|
|
|
|
# ============================================================
|
|
# Batch confirm a plan (accept all workouts + create PlannedWorkouts)
|
|
# ============================================================
|
|
|
|
@api_view(['POST'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def confirm_plan(request, plan_id):
|
|
"""
|
|
Batch-accept all workouts in a plan and create PlannedWorkout entries.
|
|
"""
|
|
registered_user = get_registered_user(request)
|
|
plan = get_object_or_404(
|
|
GeneratedWeeklyPlan,
|
|
pk=plan_id,
|
|
registered_user=registered_user,
|
|
)
|
|
|
|
workouts = GeneratedWorkout.objects.filter(plan=plan).select_related('workout')
|
|
|
|
with transaction.atomic():
|
|
workouts_to_update = []
|
|
for gw in workouts:
|
|
if gw.is_rest_day or not gw.workout:
|
|
continue
|
|
gw.status = 'accepted'
|
|
workouts_to_update.append(gw)
|
|
|
|
PlannedWorkout.objects.filter(
|
|
registered_user=registered_user,
|
|
on_date=gw.scheduled_date,
|
|
).delete()
|
|
PlannedWorkout.objects.create(
|
|
workout=gw.workout,
|
|
registered_user=registered_user,
|
|
on_date=gw.scheduled_date,
|
|
)
|
|
|
|
if workouts_to_update:
|
|
GeneratedWorkout.objects.bulk_update(workouts_to_update, ['status'])
|
|
|
|
serializer = GeneratedWeeklyPlanSerializer(plan)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
# ============================================================
|
|
# Preview-Based Generation (no DB writes until save)
|
|
# ============================================================
|
|
|
|
@api_view(['POST'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def preview_plan(request):
|
|
"""
|
|
Generate a weekly plan preview. Returns JSON -- nothing is saved to DB.
|
|
Body: {"week_start_date": "YYYY-MM-DD"}
|
|
"""
|
|
registered_user = get_registered_user(request)
|
|
|
|
week_start_date_str = request.data.get('week_start_date')
|
|
if not week_start_date_str:
|
|
return Response(
|
|
{'error': 'week_start_date is required (YYYY-MM-DD).'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
week_start_date = datetime.strptime(week_start_date_str, '%Y-%m-%d').date()
|
|
except (ValueError, TypeError):
|
|
return Response(
|
|
{'error': 'Invalid date format. Use YYYY-MM-DD.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
preference = UserPreference.objects.filter(registered_user=registered_user).first()
|
|
if not preference:
|
|
return Response(
|
|
{'error': 'User preferences not found. Please set up your preferences first.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Optional overrides
|
|
duration_minutes = request.data.get('duration_minutes')
|
|
rest_day_indices = request.data.get('rest_day_indices')
|
|
day_workout_types = request.data.get('day_workout_types')
|
|
|
|
if duration_minutes is not None:
|
|
try:
|
|
duration_minutes = int(duration_minutes)
|
|
if duration_minutes < 15 or duration_minutes > 120:
|
|
return Response(
|
|
{'error': 'duration_minutes must be between 15 and 120.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
except (ValueError, TypeError):
|
|
return Response(
|
|
{'error': 'duration_minutes must be an integer.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
from generator.services.workout_generator import WorkoutGenerator
|
|
|
|
generator = WorkoutGenerator(
|
|
preference,
|
|
duration_override=duration_minutes,
|
|
rest_day_indices=rest_day_indices,
|
|
day_workout_type_overrides=day_workout_types,
|
|
)
|
|
preview = generator.generate_weekly_preview(week_start_date)
|
|
except Exception as e:
|
|
logger.exception("Unexpected error in preview_plan")
|
|
return Response(
|
|
{"error": "An unexpected error occurred. Please try again."},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
return Response(preview, status=status.HTTP_200_OK)
|
|
|
|
|
|
@api_view(['POST'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def preview_day(request):
|
|
"""
|
|
Generate a single day preview. Returns JSON -- nothing is saved to DB.
|
|
Body: {
|
|
"target_muscles": ["chest", "shoulders"],
|
|
"focus_area": "Upper Push",
|
|
"workout_type_id": 3,
|
|
"date": "2026-02-09"
|
|
}
|
|
"""
|
|
registered_user = get_registered_user(request)
|
|
|
|
date_str = request.data.get('date')
|
|
if not date_str:
|
|
return Response(
|
|
{'error': 'date is required (YYYY-MM-DD).'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
scheduled_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
|
except (ValueError, TypeError):
|
|
return Response(
|
|
{'error': 'Invalid date format. Use YYYY-MM-DD.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
preference = UserPreference.objects.filter(registered_user=registered_user).first()
|
|
if not preference:
|
|
return Response(
|
|
{'error': 'User preferences not found.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
target_muscles = request.data.get('target_muscles', [])
|
|
focus_area = request.data.get('focus_area', 'Workout')
|
|
workout_type_id = request.data.get('workout_type_id')
|
|
|
|
workout_type = None
|
|
if workout_type_id:
|
|
workout_type = WorkoutType.objects.filter(pk=workout_type_id).first()
|
|
|
|
muscle_split = {
|
|
'label': focus_area,
|
|
'muscles': target_muscles,
|
|
'split_type': 'full_body',
|
|
}
|
|
|
|
if target_muscles:
|
|
mgs = MuscleGroupSplit.objects.filter(muscle_names=target_muscles).first()
|
|
if mgs:
|
|
muscle_split['split_type'] = mgs.split_type
|
|
|
|
# Optional plan_id: exclude exercises from sibling workouts in the same plan (Item #9)
|
|
plan_id = request.data.get('plan_id')
|
|
if plan_id in ('', None):
|
|
plan_id = None
|
|
elif not isinstance(plan_id, int):
|
|
try:
|
|
plan_id = int(plan_id)
|
|
except (TypeError, ValueError):
|
|
return Response(
|
|
{'error': 'plan_id must be an integer.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
from generator.services.workout_generator import WorkoutGenerator
|
|
|
|
generator = WorkoutGenerator(preference)
|
|
|
|
# If plan_id is provided, exclude sibling workout exercises (single query)
|
|
if plan_id is not None:
|
|
try:
|
|
plan = GeneratedWeeklyPlan.objects.get(
|
|
pk=plan_id,
|
|
registered_user=registered_user,
|
|
)
|
|
sibling_exercise_ids = set(
|
|
SupersetExercise.objects.filter(
|
|
superset__workout__generated_from__plan=plan,
|
|
superset__workout__generated_from__is_rest_day=False,
|
|
).values_list('exercise_id', flat=True)
|
|
)
|
|
if sibling_exercise_ids:
|
|
generator.exercise_selector.hard_exclude_ids.update(sibling_exercise_ids)
|
|
except GeneratedWeeklyPlan.DoesNotExist:
|
|
pass # Invalid plan_id, skip silently for backward compatibility
|
|
|
|
day_preview = generator.generate_single_day_preview(
|
|
muscle_split=muscle_split,
|
|
workout_type=workout_type,
|
|
scheduled_date=scheduled_date,
|
|
)
|
|
if plan_id is not None:
|
|
day_preview['plan_id'] = plan_id
|
|
except Exception as e:
|
|
logger.exception("Unexpected error in preview_day")
|
|
return Response(
|
|
{"error": "An unexpected error occurred. Please try again."},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
return Response(day_preview, status=status.HTTP_200_OK)
|
|
|
|
|
|
@api_view(['POST'])
|
|
@authentication_classes([TokenAuthentication])
|
|
@permission_classes([IsAuthenticated])
|
|
def save_plan(request):
|
|
"""
|
|
Save a preview plan to the database.
|
|
Body: the full preview JSON (same shape as preview_plan response).
|
|
"""
|
|
registered_user = get_registered_user(request)
|
|
|
|
week_start_date_str = request.data.get('week_start_date')
|
|
days = request.data.get('days', [])
|
|
|
|
if not week_start_date_str or not days:
|
|
return Response(
|
|
{'error': 'week_start_date and days are required.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
week_start_date = datetime.strptime(week_start_date_str, '%Y-%m-%d').date()
|
|
except (ValueError, TypeError):
|
|
return Response(
|
|
{'error': 'Invalid date format. Use YYYY-MM-DD.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
preference = UserPreference.objects.filter(registered_user=registered_user).first()
|
|
if not preference:
|
|
return Response(
|
|
{'error': 'User preferences not found.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
from generator.services.plan_builder import PlanBuilder
|
|
|
|
plan_builder = PlanBuilder(registered_user)
|
|
week_end_date = week_start_date + timedelta(days=6)
|
|
|
|
prefs_snapshot = {
|
|
'days_per_week': preference.days_per_week,
|
|
'fitness_level': preference.fitness_level,
|
|
'primary_goal': preference.primary_goal,
|
|
'secondary_goal': preference.secondary_goal,
|
|
'preferred_workout_duration': preference.preferred_workout_duration,
|
|
'preferred_days': preference.preferred_days,
|
|
'target_muscle_groups': list(
|
|
preference.target_muscle_groups.values_list('name', flat=True)
|
|
),
|
|
'available_equipment': list(
|
|
preference.available_equipment.values_list('name', flat=True)
|
|
),
|
|
'preferred_workout_types': list(
|
|
preference.preferred_workout_types.values_list('name', flat=True)
|
|
),
|
|
'injury_types': preference.injury_types or [],
|
|
'excluded_exercises': list(
|
|
preference.excluded_exercises.values_list('pk', flat=True)
|
|
),
|
|
}
|
|
|
|
# Prefetch all exercise IDs referenced in the plan to avoid N+1 queries
|
|
all_exercise_ids = []
|
|
for day_data in days:
|
|
if day_data.get('is_rest_day', False):
|
|
continue
|
|
workout_spec_data = day_data.get('workout_spec', {})
|
|
for ss_data in workout_spec_data.get('supersets', []):
|
|
for ex_data in ss_data.get('exercises', []):
|
|
exercise_id = ex_data.get('exercise_id')
|
|
if exercise_id:
|
|
all_exercise_ids.append(exercise_id)
|
|
|
|
exercises_map = {
|
|
e.id: e for e in Exercise.objects.filter(id__in=all_exercise_ids)
|
|
}
|
|
|
|
# Prefetch all workout type IDs referenced in the plan
|
|
all_workout_type_ids = []
|
|
for day_data in days:
|
|
wt_id = day_data.get('workout_type_id')
|
|
if wt_id:
|
|
all_workout_type_ids.append(wt_id)
|
|
workout_types_map = {
|
|
wt.id: wt for wt in WorkoutType.objects.filter(id__in=all_workout_type_ids)
|
|
}
|
|
|
|
with transaction.atomic():
|
|
plan = GeneratedWeeklyPlan.objects.create(
|
|
registered_user=registered_user,
|
|
week_start_date=week_start_date,
|
|
week_end_date=week_end_date,
|
|
status='completed',
|
|
preferences_snapshot=prefs_snapshot,
|
|
)
|
|
|
|
for day_data in days:
|
|
day_date_str = day_data.get('date')
|
|
scheduled_date = datetime.strptime(day_date_str, '%Y-%m-%d').date()
|
|
day_of_week = scheduled_date.weekday()
|
|
is_rest_day = day_data.get('is_rest_day', False)
|
|
|
|
if is_rest_day:
|
|
GeneratedWorkout.objects.create(
|
|
plan=plan,
|
|
workout=None,
|
|
workout_type=None,
|
|
scheduled_date=scheduled_date,
|
|
day_of_week=day_of_week,
|
|
is_rest_day=True,
|
|
status='accepted',
|
|
focus_area='Rest Day',
|
|
target_muscles=[],
|
|
)
|
|
continue
|
|
|
|
workout_spec_data = day_data.get('workout_spec', {})
|
|
focus_area = day_data.get('focus_area', 'Workout')
|
|
target_muscles = day_data.get('target_muscles', [])
|
|
workout_type_id = day_data.get('workout_type_id')
|
|
|
|
workout_type = workout_types_map.get(workout_type_id) if workout_type_id else None
|
|
|
|
supersets_data = workout_spec_data.get('supersets', [])
|
|
orm_supersets = []
|
|
for ss_data in supersets_data:
|
|
exercises = []
|
|
for ex_data in ss_data.get('exercises', []):
|
|
exercise_id = ex_data.get('exercise_id')
|
|
if not exercise_id:
|
|
continue
|
|
exercise_obj = exercises_map.get(exercise_id)
|
|
if not exercise_obj:
|
|
continue
|
|
|
|
exercises.append({
|
|
'exercise': exercise_obj,
|
|
'reps': ex_data.get('reps'),
|
|
'duration': ex_data.get('duration'),
|
|
'weight': ex_data.get('weight'),
|
|
'order': ex_data.get('order', 1),
|
|
})
|
|
|
|
orm_supersets.append({
|
|
'name': ss_data.get('name', 'Set'),
|
|
'rounds': ss_data.get('rounds', 1),
|
|
'rest_between_rounds': ss_data.get('rest_between_rounds', 0),
|
|
'exercises': exercises,
|
|
})
|
|
|
|
workout_spec = {
|
|
'name': workout_spec_data.get('name', f'{focus_area} Workout'),
|
|
'description': workout_spec_data.get('description', ''),
|
|
'supersets': orm_supersets,
|
|
}
|
|
|
|
workout_obj = plan_builder.create_workout_from_spec(workout_spec)
|
|
|
|
GeneratedWorkout.objects.create(
|
|
plan=plan,
|
|
workout=workout_obj,
|
|
workout_type=workout_type,
|
|
scheduled_date=scheduled_date,
|
|
day_of_week=day_of_week,
|
|
is_rest_day=False,
|
|
status='accepted',
|
|
focus_area=focus_area,
|
|
target_muscles=target_muscles,
|
|
)
|
|
|
|
# Create/replace PlannedWorkout for this date
|
|
PlannedWorkout.objects.filter(
|
|
registered_user=registered_user,
|
|
on_date=scheduled_date,
|
|
).delete()
|
|
PlannedWorkout.objects.create(
|
|
workout=workout_obj,
|
|
registered_user=registered_user,
|
|
on_date=scheduled_date,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.exception("Unexpected error in save_plan")
|
|
return Response(
|
|
{"error": "An unexpected error occurred. Please try again."},
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
serializer = GeneratedWeeklyPlanSerializer(plan)
|
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|