Files
WerkoutAPI/generator/views.py
Trey t 8e14fd5774 Hardening follow-up: N+1 elimination, type validation, diversify fix
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>
2026-02-27 22:33:40 -06:00

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)