import time from datetime import datetime, timedelta 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 # ============================================================ # 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 = RegisteredUser.objects.get(user=request.user) 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 = RegisteredUser.objects.get(user=request.user) 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 = RegisteredUser.objects.get(user=request.user) 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: return Response( {'error': f'Plan generation failed: {str(e)}'}, 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 = RegisteredUser.objects.get(user=request.user) plans = GeneratedWeeklyPlan.objects.filter( registered_user=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 = RegisteredUser.objects.get(user=request.user) plan = get_object_or_404( GeneratedWeeklyPlan.objects.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 = RegisteredUser.objects.get(user=request.user) generated_workout = get_object_or_404( GeneratedWorkout, 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 = RegisteredUser.objects.get(user=request.user) generated_workout = get_object_or_404( GeneratedWorkout, 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 = RegisteredUser.objects.get(user=request.user) generated_workout = get_object_or_404( GeneratedWorkout, 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 = RegisteredUser.objects.get(user=request.user) generated_workout = get_object_or_404( GeneratedWorkout, 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 (Item #9) sibling_workouts = GeneratedWorkout.objects.filter( plan=generated_workout.plan, is_rest_day=False, workout__isnull=False, ).exclude(pk=generated_workout.pk) sibling_exercise_ids = set() for sibling in sibling_workouts: if sibling.workout: sibling_exercise_ids.update( SupersetExercise.objects.filter( superset__workout=sibling.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: return Response( {'error': f'Regeneration failed: {str(e)}'}, 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 = RegisteredUser.objects.get(user=request.user) generated_workout = get_object_or_404( GeneratedWorkout, 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 = RegisteredUser.objects.get(user=request.user) 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 remaining = Superset.objects.filter(workout=workout, order__gt=deleted_order).order_by('order') for ss in remaining: ss.order -= 1 ss.save() 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 = RegisteredUser.objects.get(user=request.user) 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 remaining = SupersetExercise.objects.filter(superset=superset, order__gt=deleted_order).order_by('order') for se in remaining: se.order -= 1 se.save() # 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 = RegisteredUser.objects.get(user=request.user) 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.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 = RegisteredUser.objects.get(user=request.user) plan = get_object_or_404( GeneratedWeeklyPlan, pk=plan_id, registered_user=registered_user, ) workouts = GeneratedWorkout.objects.filter(plan=plan) for gw in workouts: if gw.is_rest_day or not gw.workout: continue gw.status = 'accepted' gw.save() 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, ) 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 = RegisteredUser.objects.get(user=request.user) 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: return Response( {'error': f'Preview generation failed: {str(e)}'}, 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 = RegisteredUser.objects.get(user=request.user) 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 if plan_id is not None: try: plan = GeneratedWeeklyPlan.objects.get( pk=plan_id, registered_user=registered_user, ) sibling_workouts = GeneratedWorkout.objects.filter( plan=plan, is_rest_day=False, workout__isnull=False, ) sibling_exercise_ids = set() for sibling in sibling_workouts: if sibling.workout: sibling_exercise_ids.update( SupersetExercise.objects.filter( superset__workout=sibling.workout ).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: return Response( {'error': f'Day preview generation failed: {str(e)}'}, 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 = RegisteredUser.objects.get(user=request.user) 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) ), } 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 = None if workout_type_id: workout_type = WorkoutType.objects.filter(pk=workout_type_id).first() 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 try: exercise_obj = Exercise.objects.get(pk=exercise_id) except Exercise.DoesNotExist: 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: return Response( {'error': f'Save failed: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) serializer = GeneratedWeeklyPlanSerializer(plan) return Response(serializer.data, status=status.HTTP_201_CREATED)