Files
WerkoutAPI/generator/services/plan_builder.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

152 lines
5.6 KiB
Python

import logging
from django.db import transaction
from workout.models import Workout
from superset.models import Superset, SupersetExercise
logger = logging.getLogger(__name__)
class PlanBuilder:
"""
Creates Django ORM objects (Workout, Superset, SupersetExercise) from
a workout specification dict. Follows the exact same creation pattern
used by the existing ``add_workout`` view.
"""
def __init__(self, registered_user):
self.registered_user = registered_user
def create_workout_from_spec(self, workout_spec):
"""
Create a full Workout with Supersets and SupersetExercises.
Parameters
----------
workout_spec : dict
Expected shape::
{
'name': 'Upper Push + Core',
'description': 'Generated workout targeting chest ...',
'supersets': [
{
'name': 'Warm Up',
'rounds': 1,
'exercises': [
{
'exercise': <Exercise instance>,
'duration': 30,
'order': 1,
},
{
'exercise': <Exercise instance>,
'reps': 10,
'weight': 50,
'order': 2,
},
],
},
...
],
}
Returns
-------
Workout
The fully-persisted Workout instance with all child objects.
"""
with transaction.atomic():
# ---- 1. Create the Workout ----
workout = Workout.objects.create(
name=workout_spec.get('name', 'Generated Workout'),
description=workout_spec.get('description', ''),
registered_user=self.registered_user,
)
workout_total_time = 0
superset_order = 1
# ---- 2. Create each Superset ----
for ss_spec in workout_spec.get('supersets', []):
ss_name = ss_spec.get('name', f'Set {superset_order}')
rounds = ss_spec.get('rounds', 1)
exercises = ss_spec.get('exercises', [])
superset = Superset.objects.create(
workout=workout,
name=ss_name,
rounds=rounds,
order=superset_order,
rest_between_rounds=ss_spec.get('rest_between_rounds', 45),
)
superset_total_time = 0
# ---- 3. Create each SupersetExercise ----
for ex_spec in exercises:
exercise_obj = ex_spec.get('exercise')
if exercise_obj is None:
logger.warning(
"Skipping exercise entry with no exercise object in "
"superset '%s'", ss_name,
)
continue
order = ex_spec.get('order', 1)
# Build kwargs for create, including optional fields,
# so we don't need a separate .save() after .create().
create_kwargs = {
'superset': superset,
'exercise': exercise_obj,
'order': order,
}
if ex_spec.get('weight') is not None:
create_kwargs['weight'] = ex_spec['weight']
if ex_spec.get('reps') is not None:
create_kwargs['reps'] = ex_spec['reps']
rep_duration = exercise_obj.estimated_rep_duration or 3.0
superset_total_time += ex_spec['reps'] * rep_duration
if ex_spec.get('duration') is not None:
create_kwargs['duration'] = ex_spec['duration']
superset_total_time += ex_spec['duration']
SupersetExercise.objects.create(**create_kwargs)
# ---- 4. Update superset estimated_time ----
# Store total time including all rounds and rest between rounds
rest_between_rounds = ss_spec.get('rest_between_rounds', 45)
rest_time = rest_between_rounds * max(0, rounds - 1)
superset.estimated_time = (superset_total_time * rounds) + rest_time
superset.save()
# Accumulate into workout total (use the already-calculated superset time)
workout_total_time += superset.estimated_time
superset_order += 1
# Add transition time between supersets
# (matches GENERATION_RULES['rest_between_supersets'] in workout_generator)
superset_count = superset_order - 1
if superset_count > 1:
rest_between_supersets = 30
workout_total_time += rest_between_supersets * (superset_count - 1)
# ---- 5. Update workout estimated_time ----
workout.estimated_time = workout_total_time
workout.save()
logger.info(
"Created workout '%s' (id=%s) with %d supersets, est. %ds",
workout.name,
workout.pk,
superset_order - 1,
workout_total_time,
)
return workout