Files
WerkoutAPI/generator/services/plan_builder.py
Trey t c80c66c2e5 Codebase hardening: 102 fixes across 35+ files
Deep audit identified 106 findings; 102 fixed, 4 deferred. Covers 8 areas:

- Settings & deploy: env-gated DEBUG/SECRET_KEY, HTTPS headers, gunicorn, celery worker
- Auth (registered_user): password write_only, request.data fixes, transaction safety, proper HTTP status codes
- Workout app: IDOR protection, get_object_or_404, prefetch_related N+1 fixes, transaction.atomic
- Video/scripts: path traversal sanitization, HLS trigger guard, auth on cache wipe
- Models (exercise/equipment/muscle/superset): null-safe __str__, stable IDs, prefetch support
- Generator views: helper for registered_user lookup, logger.exception, bulk_update, transaction wrapping
- Generator core (rules/selector/generator): push-pull ratio, type affinity normalization, modality checks, side-pair exact match, word-boundary regex, equipment cache clearing
- Generator services (plan_builder/analyzer/normalizer): transaction.atomic, muscle cache, bulk_update, glutes classification fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:29:14 -06:00

151 lines
5.5 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)
superset_exercise = SupersetExercise.objects.create(
superset=superset,
exercise=exercise_obj,
order=order,
)
# Assign optional fields exactly like add_workout does
if ex_spec.get('weight') is not None:
superset_exercise.weight = ex_spec['weight']
if ex_spec.get('reps') is not None:
superset_exercise.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:
superset_exercise.duration = ex_spec['duration']
superset_total_time += ex_spec['duration']
superset_exercise.save()
# ---- 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