Files
WerkoutAPI/generator/views.py
Trey t 03681c532d Unraid deployment fixes and generator improvements
- Add Next.js rewrites to proxy API calls through same origin (fixes login/media on werkout.treytartt.com)
- Fix mediaUrl() in DayCard and ExerciseRow to use relative paths in production
- Add proxyTimeout for long-running workout generation endpoints
- Add CSRF trusted origin for treytartt.com
- Split docker-compose into production (Unraid) and dev configs
- Show display_name and descriptions on workout type cards
- Generator: rules engine improvements, movement enforcement, exercise selector updates
- Add new test files for rules drift, workout research generation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:25:45 -06:00

1164 lines
41 KiB
Python

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)