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>
This commit is contained in:
Trey t
2026-02-27 22:29:14 -06:00
parent 63b57a83ab
commit c80c66c2e5
58 changed files with 3363 additions and 1049 deletions

View File

@@ -919,8 +919,8 @@ class Command(BaseCommand):
verbose = options['verbose']
strategy = options.get('classification_strategy', 'rules')
exercises = Exercise.objects.all()
total = exercises.count()
exercises = list(Exercise.objects.all())
total = len(exercises)
updated = 0
stats = {
'is_compound': {'True': 0, 'False': 0},
@@ -938,6 +938,10 @@ class Command(BaseCommand):
if ex.name:
name_to_exercise[ex.name] = ex
# Collect exercises to bulk_update instead of saving one at a time
exercises_to_update = []
fields_to_update = set()
for ex in exercises:
if strategy == 'regex':
from generator.management.commands.classify_exercises import classify_exercise
@@ -1003,7 +1007,9 @@ class Command(BaseCommand):
ex.stretch_position = stretch
if progression_target:
ex.progression_of = progression_target
ex.save()
exercises_to_update.append(ex)
for field, _, _ in changes:
fields_to_update.add(field)
updated += 1
if verbose:
prefix = '[DRY RUN] ' if dry_run else ''
@@ -1011,6 +1017,12 @@ class Command(BaseCommand):
for field, old, new in changes:
self.stdout.write(f' {field}: {old} -> {new}')
# Bulk update all modified exercises in batches
if exercises_to_update and not dry_run:
Exercise.objects.bulk_update(
exercises_to_update, list(fields_to_update), batch_size=500
)
# Fix #11: Correct is_weight=True on known non-weight exercises
NON_WEIGHT_OVERRIDES = ['wall sit', 'agility ladder', 'plank', 'dead hang', 'l sit']
weight_fixed = 0

View File

@@ -46,16 +46,18 @@ class Command(BaseCommand):
dry_run = options['dry_run']
rest_between_rounds = options['rest']
workouts = Workout.objects.all()
workouts = Workout.objects.prefetch_related(
'superset_workout__superset_exercises__exercise'
).all()
total = workouts.count()
updated = 0
for workout in workouts:
supersets = Superset.objects.filter(workout=workout).order_by('order')
supersets = workout.superset_workout.all().order_by('order')
workout_total_time = 0
for ss in supersets:
exercises = SupersetExercise.objects.filter(superset=ss)
exercises = ss.superset_exercises.all()
active_time = 0.0
for se in exercises:

View File

@@ -6,6 +6,7 @@ workout_research.md. Used by the quality gates in WorkoutGenerator
and the check_rules_drift management command.
"""
import re
from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any, Tuple
@@ -428,6 +429,9 @@ def _get_working_supersets(supersets: list) -> list:
def _count_push_pull(supersets: list) -> Tuple[int, int]:
"""Count push and pull exercises across working supersets.
Exercises with BOTH push AND pull patterns are counted as neutral
(neither push nor pull) to avoid double-counting.
Returns (push_count, pull_count).
"""
push_count = 0
@@ -439,9 +443,14 @@ def _count_push_pull(supersets: list) -> Tuple[int, int]:
continue
patterns = getattr(ex, 'movement_patterns', '') or ''
patterns_lower = patterns.lower()
if 'push' in patterns_lower:
is_push = 'push' in patterns_lower
is_pull = 'pull' in patterns_lower
if is_push and is_pull:
# Dual pattern — count as neutral to avoid double-counting
pass
elif is_push:
push_count += 1
if 'pull' in patterns_lower:
elif is_pull:
pull_count += 1
return push_count, pull_count
@@ -485,6 +494,31 @@ def _focus_key_for_entry(entry: dict) -> Optional[str]:
return None
def _is_recovery_entry(entry: dict) -> bool:
"""Return True when an entry is a recovery/stretch movement."""
ex = entry.get('exercise')
if ex is None:
return False
name = (getattr(ex, 'name', '') or '').lower()
# Use word boundary check to avoid over-matching (e.g. "Stretch Band Row"
# should not be flagged as recovery).
if re.search(r'\bstretch(ing|es|ed)?\b', name):
return True
patterns = (getattr(ex, 'movement_patterns', '') or '').lower()
recovery_tokens = (
'mobility - static',
'static stretch',
'cool down',
'cooldown',
'yoga',
'breathing',
'massage',
)
return any(token in patterns for token in recovery_tokens)
# ======================================================================
# Main validation function
# ======================================================================
@@ -608,7 +642,7 @@ def validate_workout(
for ss in working:
ex_count = len(ss.get('exercises', []))
# Allow 1 extra for sided pairs
if ex_count > high + 2:
if ex_count > high + 1:
violations.append(RuleViolation(
rule_id='superset_size',
severity='warning',
@@ -638,7 +672,7 @@ def validate_workout(
actual_value=ratio,
expected_range=(min_ratio, None),
))
elif push_count > 2 and pull_count == 0:
elif pull_count == 0 and push_count > 0:
violations.append(RuleViolation(
rule_id='push_pull_ratio',
severity='warning',
@@ -651,7 +685,41 @@ def validate_workout(
))
# ------------------------------------------------------------------
# 5. Focus spread across working supersets
# 5. Working-set guardrails (no recovery movements, non-zero rest)
# ------------------------------------------------------------------
for ss in working:
ss_name = ss.get('name') or 'Working set'
rest_between_rounds = ss.get('rest_between_rounds')
if rest_between_rounds is None or rest_between_rounds <= 0:
violations.append(RuleViolation(
rule_id='working_rest_missing',
severity='warning',
message=(
f"{ss_name} is missing rest_between_rounds "
"(expected a positive value)."
),
actual_value=rest_between_rounds,
expected_range=(15, None),
))
recovery_names = []
for entry in ss.get('exercises', []):
if _is_recovery_entry(entry):
ex = entry.get('exercise')
recovery_names.append(getattr(ex, 'name', 'Unknown Exercise'))
if recovery_names:
violations.append(RuleViolation(
rule_id='working_contains_recovery',
severity='error',
message=(
f"{ss_name} contains recovery/stretch movement(s): "
f"{', '.join(sorted(set(recovery_names)))}."
),
actual_value=sorted(set(recovery_names)),
))
# ------------------------------------------------------------------
# 6. Focus spread across working supersets
# ------------------------------------------------------------------
if working:
for ss in working:
@@ -697,7 +765,7 @@ def validate_workout(
previous_focus = focus_keys
# ------------------------------------------------------------------
# 6. Compound before isolation ordering
# 7. Compound before isolation ordering
# ------------------------------------------------------------------
if UNIVERSAL_RULES['compound_before_isolation']:
if not _check_compound_before_isolation(supersets):
@@ -708,7 +776,7 @@ def validate_workout(
))
# ------------------------------------------------------------------
# 7. Warmup check
# 8. Warmup check
# ------------------------------------------------------------------
if UNIVERSAL_RULES['warmup_mandatory']:
if not _has_warmup(supersets):
@@ -719,7 +787,7 @@ def validate_workout(
))
# ------------------------------------------------------------------
# 8. Cooldown check
# 9. Cooldown check
# ------------------------------------------------------------------
if not _has_cooldown(supersets):
violations.append(RuleViolation(
@@ -729,7 +797,7 @@ def validate_workout(
))
# ------------------------------------------------------------------
# 9. HIIT duration cap
# 10. HIIT duration cap
# ------------------------------------------------------------------
if wt_key == 'high_intensity_interval_training':
max_hiit_min = UNIVERSAL_RULES.get('max_hiit_duration_min', 30)
@@ -757,7 +825,7 @@ def validate_workout(
))
# ------------------------------------------------------------------
# 10. Total exercise count cap
# 11. Total exercise count cap
# ------------------------------------------------------------------
max_exercises = wt_rules.get(
'max_exercises_per_session',
@@ -780,13 +848,23 @@ def validate_workout(
))
# ------------------------------------------------------------------
# 11. Workout type match percentage (refactored from _validate_workout_type_match)
# 12. Workout type match percentage (refactored from _validate_workout_type_match)
# ------------------------------------------------------------------
_STRENGTH_TYPES = {
'traditional_strength_training', 'functional_strength_training',
'hypertrophy',
}
_HIIT_TYPES = {'high_intensity_interval_training'}
_CARDIO_TYPES = {'cardio'}
_CORE_TYPES = {'core_training'}
_FLEXIBILITY_TYPES = {'flexibility'}
is_strength = wt_key in _STRENGTH_TYPES
is_hiit = wt_key in _HIIT_TYPES
is_cardio = wt_key in _CARDIO_TYPES
is_core = wt_key in _CORE_TYPES
is_flexibility = wt_key in _FLEXIBILITY_TYPES
if working:
total_ex = 0
matching_ex = 0
@@ -799,7 +877,33 @@ def validate_workout(
if is_strength:
if getattr(ex, 'is_weight', False) or getattr(ex, 'is_compound', False):
matching_ex += 1
elif is_hiit:
# HIIT: favor high HR, compound, or duration-capable exercises
hr = getattr(ex, 'hr_elevation_rating', None) or 0
if hr >= 5 or getattr(ex, 'is_compound', False) or getattr(ex, 'is_duration', False):
matching_ex += 1
elif is_cardio:
# Cardio: favor duration-capable or high-HR exercises
hr = getattr(ex, 'hr_elevation_rating', None) or 0
if getattr(ex, 'is_duration', False) or hr >= 5:
matching_ex += 1
elif is_core:
# Core: check if exercise targets core muscles
muscles = (getattr(ex, 'muscle_groups', '') or '').lower()
patterns = (getattr(ex, 'movement_patterns', '') or '').lower()
if any(tok in muscles for tok in ('core', 'abs', 'oblique')):
matching_ex += 1
elif 'core' in patterns or 'anti' in patterns:
matching_ex += 1
elif is_flexibility:
# Flexibility: favor duration-based, stretch/mobility exercises
patterns = (getattr(ex, 'movement_patterns', '') or '').lower()
if getattr(ex, 'is_duration', False) or any(
tok in patterns for tok in ('stretch', 'mobility', 'yoga', 'flexibility')
):
matching_ex += 1
else:
# Unknown type — count all as matching (no false negatives)
matching_ex += 1
if total_ex > 0:
match_pct = matching_ex / total_ex

View File

@@ -335,7 +335,11 @@ class GeneratedWorkoutDetailSerializer(serializers.ModelSerializer):
def get_supersets(self, obj):
if obj.workout:
superset_qs = Superset.objects.filter(workout=obj.workout).order_by('order')
superset_qs = Superset.objects.filter(
workout=obj.workout
).prefetch_related(
'supersetexercise_set__exercise',
).order_by('order')
return SupersetSerializer(superset_qs, many=True).data
return []

View File

@@ -160,13 +160,16 @@ class ExerciseSelector:
self._exercise_profile_cache = {}
self.warnings = [] # Phase 13: generation warnings
self.progression_boost_ids = set() # IDs of exercises that are progressions of recently done ones
# Week-scoped state for cross-day dedup (NOT cleared by reset())
self.week_used_exercise_ids = set()
self.week_used_movement_families = Counter()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def reset(self):
"""Reset used exercises for a new workout."""
"""Reset used exercises for a new workout (preserves week-scoped state)."""
self.used_exercise_ids = set()
self.used_exercise_names = set()
self.used_movement_patterns = Counter()
@@ -175,6 +178,49 @@ class ExerciseSelector:
self.last_working_similarity_profiles = []
self._exercise_profile_cache = {}
self.warnings = []
# Clear per-queryset caches so equipment/exclusion changes take effect
if hasattr(self, '_equipment_map_cache'):
del self._equipment_map_cache
if hasattr(self, '_bodyweight_ids_cache'):
del self._bodyweight_ids_cache
if hasattr(self, '_warned_small_pool'):
del self._warned_small_pool
if hasattr(self, '_warned_no_equipment'):
del self._warned_no_equipment
if hasattr(self, '_relaxed_hard_exclude_ids'):
del self._relaxed_hard_exclude_ids
if hasattr(self, '_injury_warnings_emitted'):
del self._injury_warnings_emitted
def reset_week(self):
"""Reset all state including week-scoped tracking. Call at start of a new week."""
self.reset()
self.week_used_exercise_ids = set()
self.week_used_movement_families = Counter()
def accumulate_week_state(self, exercise_ids, exercise_names):
"""Record a completed day's exercises into week-scoped tracking.
Parameters
----------
exercise_ids : set[int]
Primary keys of exercises used in the day's workout.
exercise_names : set[str]
Exercise names (used for family extraction).
"""
self.week_used_exercise_ids.update(exercise_ids)
for name in exercise_names:
for fam in extract_movement_families(name):
self.week_used_movement_families[fam] += 1
def _get_week_family_limit(self, family):
"""Max allowed uses of a movement family across the whole week.
Wider than per-workout limits: narrow families = 2/week, broad = 4/week.
"""
if family in NARROW_FAMILIES:
return 2
return 4
def select_exercises(
self,
@@ -184,6 +230,7 @@ class ExerciseSelector:
movement_pattern_preference=None,
prefer_weighted=False,
superset_position=None,
allow_cross_modality=False,
):
"""
Select *count* exercises matching the given criteria.
@@ -200,6 +247,10 @@ class ExerciseSelector:
Optional list of preferred movement patterns to favour.
prefer_weighted : bool
When True (R6), boost is_weight=True exercises in selection.
allow_cross_modality : bool
When True, don't hard-filter by modality — instead use soft
preference so duration-only exercises (carries, planks) can
land in rep-based supersets and vice versa.
Returns
-------
@@ -209,13 +260,19 @@ class ExerciseSelector:
return []
fitness_level = getattr(self.user_preference, 'fitness_level', None)
# When cross-modality is allowed, skip the hard modality filter
# so duration-only exercises can appear in rep supersets and vice versa.
modality_for_filter = None if allow_cross_modality else is_duration_based
preferred_modality = 'duration' if is_duration_based else 'reps'
qs = self._get_filtered_queryset(
muscle_groups=muscle_groups,
is_duration_based=is_duration_based,
is_duration_based=modality_for_filter,
fitness_level=fitness_level,
)
# Working supersets should not contain stretch/recovery exercises.
excluded_q = Q(name__icontains='stretch')
# Use regex word boundary to avoid over-matching (e.g. "Stretch Band Row"
# should NOT be excluded, but "Hamstring Stretch" should).
excluded_q = Q(name__iregex=r'\bstretch(ing|es|ed)?\b')
for pat in self.WORKING_EXCLUDED_PATTERNS:
excluded_q |= Q(movement_patterns__icontains=pat)
qs = qs.exclude(excluded_q)
@@ -258,6 +315,7 @@ class ExerciseSelector:
count,
superset_position=superset_position,
similarity_scope='working',
preferred_modality=preferred_modality if allow_cross_modality else None,
)
# Sort selected exercises by tier: primary first, then secondary, then accessory
@@ -288,14 +346,16 @@ class ExerciseSelector:
for missing_muscle in uncovered:
replacement_qs = self._get_filtered_queryset(
muscle_groups=[missing_muscle],
is_duration_based=is_duration_based,
is_duration_based=modality_for_filter,
fitness_level=fitness_level,
).exclude(pk__in={e.pk for e in selected})
# Validate modality: ensure replacement matches expected modality
if is_duration_based:
replacement_qs = replacement_qs.filter(is_duration=True)
elif is_duration_based is False:
replacement_qs = replacement_qs.filter(is_reps=True)
# (skip when cross-modality is allowed)
if not allow_cross_modality:
if is_duration_based:
replacement_qs = replacement_qs.filter(is_duration=True)
elif is_duration_based is False:
replacement_qs = replacement_qs.filter(is_reps=True)
replacement = list(replacement_qs[:1])
if replacement:
# Find last unswapped accessory
@@ -382,8 +442,6 @@ class ExerciseSelector:
is_duration_based=True,
fitness_level=fitness_level,
)
# Avoid duplicate-looking left/right variants in recovery sections.
qs = qs.filter(Q(side__isnull=True) | Q(side=''))
# Prefer exercises whose movement_patterns overlap with warmup keywords
warmup_q = Q()
@@ -420,7 +478,6 @@ class ExerciseSelector:
is_duration_based=True,
fitness_level=fitness_level,
).exclude(pk__in={e.pk for e in selected})
wide_qs = wide_qs.filter(Q(side__isnull=True) | Q(side=''))
# Apply same warmup safety exclusions
wide_qs = wide_qs.exclude(is_weight=True)
wide_qs = wide_qs.exclude(is_compound=True)
@@ -440,7 +497,8 @@ class ExerciseSelector:
self._track_families(selected)
selected = self._ensure_side_pair_integrity(selected, qs, count=count)
return self._trim_preserving_pairs(selected, count)
selected = self._trim_preserving_pairs(selected, count)
return self._order_side_pairs_adjacent(selected)
def select_cooldown_exercises(self, target_muscles, count=4):
"""
@@ -456,8 +514,6 @@ class ExerciseSelector:
is_duration_based=True,
fitness_level=fitness_level,
)
# Avoid duplicate-looking left/right variants in recovery sections.
qs = qs.filter(Q(side__isnull=True) | Q(side=''))
cooldown_q = Q()
for kw in self.COOLDOWN_PATTERNS:
@@ -489,7 +545,6 @@ class ExerciseSelector:
is_duration_based=True,
fitness_level=fitness_level,
).exclude(pk__in={e.pk for e in selected})
wide_qs = wide_qs.filter(Q(side__isnull=True) | Q(side=''))
# Apply same exclusions
wide_qs = wide_qs.exclude(exclude_q)
# R11: also apply weight filter on wide fallback
@@ -509,7 +564,8 @@ class ExerciseSelector:
self._track_families(selected)
selected = self._ensure_side_pair_integrity(selected, qs, count=count)
return self._trim_preserving_pairs(selected, count)
selected = self._trim_preserving_pairs(selected, count)
return self._order_side_pairs_adjacent(selected)
# ------------------------------------------------------------------
# Internal helpers
@@ -568,37 +624,31 @@ class ExerciseSelector:
qs = qs.exclude(name_exclude_q)
# ---- Hard exclude exercises from recent workouts (Phase 6) ----
# Adaptive: if pool would be too small, relax hard exclude to soft penalty
# Adaptive: if pool would be too small, relax hard exclude to soft penalty.
# Use a local merged set to avoid permanently polluting recently_used_ids.
if self.hard_exclude_ids:
test_qs = qs.exclude(pk__in=self.hard_exclude_ids)
if test_qs.count() >= 10:
qs = test_qs
else:
# Pool too small — convert hard exclude to soft penalty instead
self.recently_used_ids = self.recently_used_ids | self.hard_exclude_ids
if not hasattr(self, '_warned_small_pool'):
self.warnings.append(
'Exercise pool too small for full variety rotation — '
'relaxed recent exclusion to soft penalty.'
)
self._warned_small_pool = True
# Pool too small — treat hard excludes as soft penalty for this
# queryset only (don't mutate the original recently_used_ids).
if not hasattr(self, '_relaxed_hard_exclude_ids'):
self._relaxed_hard_exclude_ids = set(self.hard_exclude_ids)
if not hasattr(self, '_warned_small_pool'):
self.warnings.append(
'Exercise pool too small for full variety rotation — '
'relaxed recent exclusion to soft penalty.'
)
self._warned_small_pool = True
# ---- Filter by user's available equipment ----
available_equipment_ids = set(
self.user_preference.available_equipment.values_list('pk', flat=True)
)
if not available_equipment_ids:
# No equipment set: only allow bodyweight exercises (no WorkoutEquipment entries)
exercises_with_equipment = set(
WorkoutEquipment.objects.values_list('exercise_id', flat=True).distinct()
)
qs = qs.exclude(pk__in=exercises_with_equipment)
if not hasattr(self, '_warned_no_equipment'):
self.warnings.append(
'No equipment set — using bodyweight exercises only. '
'Update your equipment preferences for more variety.'
)
self._warned_no_equipment = True
# No equipment set in preferences — all exercises are available (no filtering).
pass
elif available_equipment_ids:
# Cache equipment map on instance to avoid rebuilding per call
if not hasattr(self, '_equipment_map_cache'):
@@ -895,6 +945,7 @@ class ExerciseSelector:
count,
superset_position=None,
similarity_scope=None,
preferred_modality=None,
):
"""
Pick up to *count* exercises using weighted random selection.
@@ -909,6 +960,10 @@ class ExerciseSelector:
superset_position: 'early', 'late', or None. When set, boosts
exercises based on their exercise_tier (primary for early,
accessory for late).
preferred_modality: 'reps' or 'duration' or None. When set,
exercises that don't match the preferred modality get 0.3x weight
(cross-modality penalty). Dual-modality exercises always get full weight.
"""
if count <= 0:
return []
@@ -932,12 +987,49 @@ class ExerciseSelector:
return base_w * 2
return base_w
def _apply_week_penalty(ex, base_w):
"""Soft-penalize exercises already used earlier in the week."""
w = base_w
if self.week_used_exercise_ids and ex.pk in self.week_used_exercise_ids:
w = max(1, w // 2)
if self.week_used_movement_families:
for fam in extract_movement_families(ex.name):
if self.week_used_movement_families.get(fam, 0) >= self._get_week_family_limit(fam):
w = max(1, w // 2)
break
return w
def _apply_modality_penalty(ex, base_w):
"""Soft-penalize exercises that don't match the preferred modality.
Dual-modality exercises (is_reps AND is_duration) get full weight.
Cross-modality exercises get 0.3x weight (minimum 1).
"""
if not preferred_modality:
return base_w
is_reps = getattr(ex, 'is_reps', False)
is_dur = getattr(ex, 'is_duration', False)
# Dual-modality: always full weight
if is_reps and is_dur:
return base_w
if preferred_modality == 'reps' and is_reps:
return base_w
if preferred_modality == 'duration' and is_dur:
return base_w
# Cross-modality: reduce to ~30% of base weight
return max(1, int(base_w * 0.3))
# Build effective soft-penalty set: recently_used + any relaxed hard excludes
_effective_recently_used = self.recently_used_ids
if hasattr(self, '_relaxed_hard_exclude_ids') and self._relaxed_hard_exclude_ids:
_effective_recently_used = self.recently_used_ids | self._relaxed_hard_exclude_ids
for ex in preferred_list:
w = weight_preferred
# Boost exercises that are progressions of recently completed exercises
if ex.pk in self.progression_boost_ids:
w = w * 2
if ex.pk in self.recently_used_ids:
if ex.pk in _effective_recently_used:
w = 1 # Reduce weight for recently used
# Penalize overused movement patterns for variety (Phase 11)
# Fixed: check ALL comma-separated patterns, use max count
@@ -953,12 +1045,16 @@ class ExerciseSelector:
w = 1
elif max_pat_count >= 2:
w = max(1, w - 1)
w = _apply_week_penalty(ex, w)
w = _apply_modality_penalty(ex, w)
w = _tier_boost(ex, w)
pool.extend([ex] * w)
for ex in other_list:
w = weight_other
if ex.pk in self.recently_used_ids:
if ex.pk in _effective_recently_used:
w = 1 # Already 1 but keep explicit
w = _apply_week_penalty(ex, w)
w = _apply_modality_penalty(ex, w)
w = _tier_boost(ex, w)
pool.extend([ex] * w)
@@ -1153,23 +1249,26 @@ class ExerciseSelector:
if not opposite_norm:
continue
# Find the matching partner by name similarity and opposite side
# Find the matching partner by exact base-name match and opposite side.
# Typically the name is identical except for side, e.g.
# "Single Arm Row Left" / "Single Arm Row Right"
base_name = ex.name
for side_word in ['Left', 'Right', 'left', 'right']:
base_name = base_name.replace(side_word, '').strip()
base_name = self._strip_side_tokens(ex.name)
partner = (
# Use strict matching: find candidates with opposite side,
# then filter in Python by exact base-name match to avoid
# substring false positives (e.g. "L Sit" matching "Wall Sit").
partner_candidates = (
Exercise.objects
.filter(
name__icontains=base_name,
)
.filter(self._side_values_q(opposite_norm))
.exclude(pk__in=self.used_exercise_ids)
.exclude(pk__in=paired_ids)
.first()
)
partner = None
for candidate in partner_candidates:
candidate_base = self._strip_side_tokens(candidate.name)
if base_name.lower() == candidate_base.lower():
partner = candidate
break
if partner and partner.pk not in paired_ids:
exercises_to_add.append(partner)
@@ -1184,12 +1283,11 @@ class ExerciseSelector:
# Check if any partner should follow this exercise
for partner in exercises_to_add:
if partner.pk not in added_ids:
# Check if partner is the pair for this exercise
# Check if partner is the pair for this exercise using exact base-name match
if ex.side and ex.side.strip():
base_name = ex.name
for side_word in ['Left', 'Right', 'left', 'right']:
base_name = base_name.replace(side_word, '').strip()
if base_name.lower() in partner.name.lower():
ex_base = self._strip_side_tokens(ex.name)
partner_base = self._strip_side_tokens(partner.name)
if ex_base.lower() == partner_base.lower():
final.append(partner)
added_ids.add(partner.pk)
@@ -1265,6 +1363,57 @@ class ExerciseSelector:
return result
def _order_side_pairs_adjacent(self, selected):
"""
Keep left/right variants adjacent in list order.
This is primarily for warm-up/cool-down UX so side-specific movements
render one after another instead of grouped by side.
"""
if len(selected) < 2:
return selected
side_map = {}
for ex in selected:
side_val = self._normalize_side_value(getattr(ex, 'side', ''))
if side_val not in ('left', 'right'):
continue
key = self._strip_side_tokens(getattr(ex, 'name', ''))
side_map.setdefault(key, {'left': [], 'right': []})
side_map[key][side_val].append(ex)
ordered = []
used_ids = set()
for ex in selected:
if ex.pk in used_ids:
continue
side_val = self._normalize_side_value(getattr(ex, 'side', ''))
if side_val in ('left', 'right'):
key = self._strip_side_tokens(getattr(ex, 'name', ''))
opposite = self._opposite_side(side_val)
opposite_ex = None
for candidate in side_map.get(key, {}).get(opposite, []):
if candidate.pk not in used_ids:
opposite_ex = candidate
break
if opposite_ex:
ordered.append(ex)
ordered.append(opposite_ex)
used_ids.add(ex.pk)
used_ids.add(opposite_ex.pk)
continue
ordered.append(ex)
used_ids.add(ex.pk)
for ex in selected:
if ex.pk not in used_ids:
ordered.append(ex)
used_ids.add(ex.pk)
return ordered
def _strip_side_tokens(self, name):
"""Normalize a name by removing left/right tokens."""
base = name or ''

View File

@@ -157,7 +157,7 @@ MUSCLE_GROUP_CATEGORIES: dict[str, list[str]] = {
'traps', 'forearms', 'rotator cuff',
],
'lower_push': [
'quads', 'calves', 'glutes', 'hip abductors', 'hip adductors',
'quads', 'calves', 'hip abductors', 'hip adductors',
],
'lower_pull': [
'hamstrings', 'glutes', 'lower back', 'hip flexors',
@@ -202,6 +202,9 @@ def normalize_muscle_name(name: Optional[str]) -> Optional[str]:
return key
_muscle_cache: dict[int, Set[str]] = {}
def get_muscles_for_exercise(exercise) -> Set[str]:
"""
Return the set of normalized muscle names for a given Exercise instance.
@@ -209,7 +212,12 @@ def get_muscles_for_exercise(exercise) -> Set[str]:
Uses the ExerciseMuscle join table (exercise.exercise_muscle_exercise).
Falls back to the comma-separated Exercise.muscle_groups field if no
ExerciseMuscle rows exist.
Results are cached per exercise ID to avoid repeated DB queries.
"""
if exercise.id in _muscle_cache:
return _muscle_cache[exercise.id]
from muscle.models import ExerciseMuscle
muscles: Set[str] = set()
@@ -229,9 +237,15 @@ def get_muscles_for_exercise(exercise) -> Set[str]:
if normalized:
muscles.add(normalized)
_muscle_cache[exercise.id] = muscles
return muscles
def clear_muscle_cache() -> None:
"""Clear the muscle cache (useful for testing or re-analysis)."""
_muscle_cache.clear()
def get_movement_patterns_for_exercise(exercise) -> List[str]:
"""
Parse the comma-separated movement_patterns CharField on Exercise and

View File

@@ -1,5 +1,7 @@
import logging
from django.db import transaction
from workout.models import Workout
from superset.models import Superset, SupersetExercise
@@ -55,88 +57,87 @@ class PlanBuilder:
Workout
The fully-persisted Workout instance with all child objects.
"""
# ---- 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.save()
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),
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,
)
superset.save()
superset_total_time = 0
workout_total_time = 0
superset_order = 1
# ---- 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
# ---- 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', [])
order = ex_spec.get('order', 1)
superset_exercise = SupersetExercise.objects.create(
superset=superset,
exercise=exercise_obj,
order=order,
superset = Superset.objects.create(
workout=workout,
name=ss_name,
rounds=rounds,
order=superset_order,
rest_between_rounds=ss_spec.get('rest_between_rounds', 45),
)
# Assign optional fields exactly like add_workout does
if ex_spec.get('weight') is not None:
superset_exercise.weight = ex_spec['weight']
superset_total_time = 0
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
# ---- 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
if ex_spec.get('duration') is not None:
superset_exercise.duration = ex_spec['duration']
superset_total_time += ex_spec['duration']
order = ex_spec.get('order', 1)
superset_exercise.save()
superset_exercise = SupersetExercise.objects.create(
superset=superset,
exercise=exercise_obj,
order=order,
)
# ---- 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()
# Assign optional fields exactly like add_workout does
if ex_spec.get('weight') is not None:
superset_exercise.weight = ex_spec['weight']
# Accumulate into workout total (use the already-calculated superset time)
workout_total_time += superset.estimated_time
superset_order += 1
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
# 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)
if ex_spec.get('duration') is not None:
superset_exercise.duration = ex_spec['duration']
superset_total_time += ex_spec['duration']
# ---- 5. Update workout estimated_time ----
workout.estimated_time = workout_total_time
workout.save()
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",

View File

@@ -27,6 +27,7 @@ from typing import Dict, List, Optional, Set, Tuple
import numpy as np
from django.db import transaction
from django.db.models import Count, Prefetch, Q
from exercise.models import Exercise
@@ -225,14 +226,15 @@ class WorkoutAnalyzer:
print(' Workout Analyzer - ML Pattern Extraction')
print('=' * 64)
self._clear_existing_patterns()
self._step1_populate_workout_types()
self._step2_extract_workout_data()
self._step3_extract_muscle_group_splits()
self._step4_extract_weekly_split_patterns()
self._step5_extract_workout_structure_rules()
self._step6_extract_movement_pattern_ordering()
self._step7_ensure_full_rule_coverage()
with transaction.atomic():
self._clear_existing_patterns()
self._step1_populate_workout_types()
self._step2_extract_workout_data()
self._step3_extract_muscle_group_splits()
self._step4_extract_weekly_split_patterns()
self._step5_extract_workout_structure_rules()
self._step6_extract_movement_pattern_ordering()
self._step7_ensure_full_rule_coverage()
print('\n' + '=' * 64)
print(' Analysis complete.')
@@ -1325,16 +1327,19 @@ class WorkoutAnalyzer:
},
}
# Prefetch all existing rules into an in-memory set to avoid
# N exists() queries (one per workout_type x section x goal combination).
existing_rules = set(
WorkoutStructureRule.objects.values_list(
'workout_type_id', 'section_type', 'goal_type'
)
)
created = 0
for wt in workout_types:
for section in all_sections:
for goal in all_goals:
exists = WorkoutStructureRule.objects.filter(
workout_type=wt,
section_type=section,
goal_type=goal,
).exists()
if not exists:
if (wt.pk, section, goal) not in existing_rules:
defaults = dict(section_defaults[section])
# Apply goal adjustments
base_params = {

View File

@@ -0,0 +1,6 @@
"""Pure workout generation utilities.
These helpers are intentionally side-effect free so they can be tested
independently from Django models and service orchestration.
"""

View File

@@ -0,0 +1,39 @@
import math
import random
def pick_reps_for_exercise(exercise, wt_params: dict, tier_ranges: dict, rng=random) -> int:
"""Pick reps from tier-specific range, then fallback to generic wt params."""
tier = (getattr(exercise, 'exercise_tier', None) or 'accessory').lower()
selected_range = tier_ranges.get(tier) or (wt_params['rep_min'], wt_params['rep_max'])
low, high = int(selected_range[0]), int(selected_range[1])
if low > high:
low, high = high, low
return rng.randint(low, high)
def apply_rep_volume_floor(entries: list[dict], rounds: int, min_volume: int) -> None:
"""Mutate entries in-place so reps*rounds meets the minimum volume floor."""
if rounds <= 0:
return
for entry in entries:
reps = entry.get('reps')
if reps and reps * rounds < min_volume:
entry['reps'] = max(reps, math.ceil(min_volume / rounds))
def working_rest_seconds(rest_override, default_rest: int, minimum_rest: int = 15) -> int:
"""Return guarded positive working rest in seconds."""
rest = rest_override or default_rest or 45
return max(minimum_rest, int(rest))
def sort_entries_by_hr(entries: list[dict], is_early_block: bool) -> None:
"""Sort entries by HR elevation and re-number order."""
entries.sort(
key=lambda e: getattr(e.get('exercise'), 'hr_elevation_rating', 5) or 5,
reverse=is_early_block,
)
for idx, entry in enumerate(entries, start=1):
entry['order'] = idx

View File

@@ -0,0 +1,41 @@
from typing import Optional
from generator.services.exercise_selector import extract_movement_families
def focus_key_for_exercise(exercise) -> Optional[str]:
"""Classify exercise into a coarse focus key used for variety checks."""
if exercise is None:
return None
families = sorted(extract_movement_families(getattr(exercise, 'name', '') or ''))
if families:
return families[0]
patterns = (getattr(exercise, 'movement_patterns', '') or '').lower()
for token in ('upper pull', 'upper push', 'hip hinge', 'squat', 'lunge', 'core', 'carry'):
if token in patterns:
return token
return None
def has_duplicate_focus(exercises: list) -> bool:
"""True when two exercises in one superset map to the same focus key."""
seen = set()
for ex in exercises or []:
key = focus_key_for_exercise(ex)
if not key:
continue
if key in seen:
return True
seen.add(key)
return False
def focus_keys_for_exercises(exercises: list) -> set:
"""Return non-empty focus keys for a list of exercises."""
keys = set()
for ex in exercises or []:
key = focus_key_for_exercise(ex)
if key:
keys.add(key)
return keys

View File

@@ -0,0 +1,53 @@
import math
import random
from typing import Optional
def clamp_duration_bias(duration_bias: float, duration_bias_range: Optional[tuple]) -> float:
"""Clamp duration bias to [0,1] or workout-type specific range."""
if not duration_bias_range:
return max(0.0, min(1.0, duration_bias))
low, high = duration_bias_range
return max(float(low), min(float(high), duration_bias))
def plan_superset_modalities(
*,
num_supersets: int,
duration_bias: float,
duration_bias_range: Optional[tuple],
is_strength_workout: bool,
rng=random,
) -> list[bool]:
"""Plan per-superset modality (True=duration, False=reps)."""
if num_supersets <= 0:
return []
if is_strength_workout:
return [False] * num_supersets
if duration_bias_range:
low, high = duration_bias_range
target_bias = (float(low) + float(high)) / 2.0
min_duration_sets = max(0, math.ceil(num_supersets * float(low)))
max_duration_sets = min(num_supersets, math.floor(num_supersets * float(high)))
else:
target_bias = max(0.0, min(1.0, duration_bias))
min_duration_sets = max(0, math.floor(num_supersets * max(0.0, target_bias - 0.15)))
max_duration_sets = min(num_supersets, math.ceil(num_supersets * min(1.0, target_bias + 0.15)))
duration_sets = int(round(num_supersets * target_bias))
duration_sets = max(min_duration_sets, min(max_duration_sets, duration_sets))
if num_supersets > 1 and duration_sets == num_supersets and max_duration_sets < num_supersets:
duration_sets = max_duration_sets
if num_supersets > 1 and duration_sets == 0 and min_duration_sets > 0:
duration_sets = min_duration_sets
modalities = [False] * num_supersets
if duration_sets > 0:
positions = list(range(num_supersets))
rng.shuffle(positions)
for idx in positions[:duration_sets]:
modalities[idx] = True
return modalities

View File

@@ -0,0 +1,26 @@
def working_position_label(ss_idx: int, num_supersets: int) -> str:
"""Return early/middle/late position label for a working superset index."""
if num_supersets <= 1 or ss_idx == 0:
return 'early'
if ss_idx >= num_supersets - 1:
return 'late'
return 'middle'
def merge_pattern_preferences(position_patterns, rule_patterns):
"""Combine positional and structure-rule pattern preferences."""
if rule_patterns and position_patterns:
overlap = [p for p in position_patterns if p in rule_patterns]
return overlap or rule_patterns[:3]
if rule_patterns:
return rule_patterns[:3]
return position_patterns
def rotated_muscle_subset(target_muscles: list[str], ss_idx: int) -> list[str]:
"""Rotate target muscle emphasis between supersets."""
if len(target_muscles) <= 1:
return target_muscles
start = ss_idx % len(target_muscles)
return target_muscles[start:] + target_muscles[:start]

View File

@@ -0,0 +1,14 @@
def is_recovery_exercise(ex) -> bool:
"""True for warmup/cooldown-style recovery/stretch exercises."""
if ex is None:
return False
name = (getattr(ex, 'name', '') or '').lower()
patterns = (getattr(ex, 'movement_patterns', '') or '').lower()
if 'stretch' in name:
return True
blocked = (
'mobility - static', 'static stretch', 'yoga',
'cool down', 'cooldown', 'breathing', 'massage',
)
return any(token in patterns for token in blocked)

View File

@@ -0,0 +1,31 @@
def apply_fitness_scaling(
params: dict,
*,
fitness_level: int,
scaling_config: dict,
min_reps: int,
min_reps_strength: int,
is_strength: bool = False,
) -> dict:
"""Scale workout params based on fitness level."""
out = dict(params)
level = fitness_level or 2
scaling = scaling_config.get(level, scaling_config[2])
rep_floor = min_reps_strength if is_strength else min_reps
out['rep_min'] = max(rep_floor, int(out['rep_min'] * scaling['rep_min_mult']))
out['rep_max'] = max(out['rep_min'], int(out['rep_max'] * scaling['rep_max_mult']))
rounds_min, rounds_max = out['rounds']
rounds_min = max(1, rounds_min + scaling['rounds_adj'])
rounds_max = max(rounds_min, rounds_max + scaling['rounds_adj'])
out['rounds'] = (rounds_min, rounds_max)
rest = out.get('rest_between_rounds', 45)
out['rest_between_rounds'] = max(15, rest + scaling['rest_adj'])
if level <= 1 and is_strength:
out['rep_min'] = max(5, out['rep_min'])
out['rep_max'] = max(out['rep_min'], out['rep_max'])
return out

View File

@@ -0,0 +1,68 @@
import random
from typing import Iterable, Optional
def section_exercise_count(section: str, fitness_level: int, rng=random) -> int:
"""Return section exercise count range by fitness level."""
level = fitness_level or 2
if section == 'warmup':
if level <= 1:
return rng.randint(5, 7)
if level >= 3:
return rng.randint(3, 5)
return rng.randint(4, 6)
if section == 'cooldown':
if level <= 1:
return rng.randint(4, 5)
if level >= 3:
return rng.randint(2, 3)
return rng.randint(3, 4)
raise ValueError(f'Unknown section: {section}')
def rounded_duration(
raw_duration: int,
*,
min_duration: int,
duration_multiple: int,
) -> int:
"""Round duration to configured multiple and clamp to minimum."""
return max(min_duration, round(raw_duration / duration_multiple) * duration_multiple)
def build_duration_entries(
exercises: Iterable,
*,
duration_min: int,
duration_max: int,
min_duration: int,
duration_multiple: int,
rng=random,
) -> list[dict]:
"""Build ordered duration entries from exercises."""
entries = []
for idx, ex in enumerate(exercises, start=1):
duration = rng.randint(duration_min, duration_max)
entries.append({
'exercise': ex,
'duration': rounded_duration(
duration,
min_duration=min_duration,
duration_multiple=duration_multiple,
),
'order': idx,
})
return entries
def build_section_superset(name: str, entries: list[dict]) -> Optional[dict]:
"""Build a single-round warmup/cooldown superset payload."""
if not entries:
return None
return {
'name': name,
'rounds': 1,
'rest_between_rounds': 0,
'exercises': entries,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
from django.contrib.auth.models import User
from django.test import TestCase
from exercise.models import Exercise
from generator.models import UserPreference
from generator.services.exercise_selector import (
ExerciseSelector,
extract_movement_families,
)
from registered_user.models import RegisteredUser
class TestExerciseFamilyDedup(TestCase):
def setUp(self):
django_user = User.objects.create_user(
username='family_dedup_user',
password='testpass123',
)
registered_user = RegisteredUser.objects.create(
user=django_user,
first_name='Family',
last_name='Dedup',
)
self.preference = UserPreference.objects.create(
registered_user=registered_user,
days_per_week=4,
fitness_level=2,
)
def test_high_pull_maps_to_clean_family(self):
clean_pull_families = extract_movement_families('Barbell Clean Pull')
high_pull_families = extract_movement_families('Barbell High Pull')
self.assertIn('clean', clean_pull_families)
self.assertIn('clean', high_pull_families)
def test_high_pull_blocked_when_clean_family_already_used(self):
high_pull = Exercise.objects.create(
name='Barbell High Pull',
movement_patterns='lower pull,lower pull - hip hinge',
muscle_groups='glutes,hamstrings,traps',
is_reps=True,
is_duration=False,
is_weight=True,
is_compound=True,
exercise_tier='secondary',
complexity_rating=3,
difficulty_level='intermediate',
)
selector = ExerciseSelector(self.preference)
selector.used_movement_families['clean'] = 1
selected = selector._weighted_pick(
Exercise.objects.filter(pk=high_pull.pk),
Exercise.objects.none(),
count=1,
)
self.assertEqual(
selected,
[],
'High-pull variant should be blocked when clean family is already used.',
)

View File

@@ -0,0 +1,142 @@
from django.contrib.auth.models import User
from django.test import TestCase
from exercise.models import Exercise
from generator.models import UserPreference
from generator.services.exercise_selector import ExerciseSelector
from registered_user.models import RegisteredUser
class TestExerciseSimilarityDedup(TestCase):
def setUp(self):
django_user = User.objects.create_user(
username='similarity_dedup_user',
password='testpass123',
)
registered_user = RegisteredUser.objects.create(
user=django_user,
first_name='Similarity',
last_name='Dedup',
)
self.preference = UserPreference.objects.create(
registered_user=registered_user,
days_per_week=4,
fitness_level=2,
)
def test_hard_similarity_blocks_near_identical_working_exercise(self):
selector = ExerciseSelector(self.preference)
prior = Exercise.objects.create(
name='Posterior Chain Pull Alpha',
movement_patterns='lower pull, lower pull - hip hinge',
muscle_groups='glutes,hamstrings,traps',
equipment_required='barbell',
is_reps=True,
is_duration=False,
is_weight=True,
is_compound=True,
difficulty_level='intermediate',
)
candidate = Exercise.objects.create(
name='Posterior Chain Pull Beta',
movement_patterns='lower pull, lower pull - hip hinge',
muscle_groups='glutes,hamstrings,traps',
equipment_required='barbell',
is_reps=True,
is_duration=False,
is_weight=True,
is_compound=True,
difficulty_level='intermediate',
)
selector.used_working_similarity_profiles.append(
selector._build_similarity_profile(prior)
)
selected = selector._weighted_pick(
Exercise.objects.filter(pk=candidate.pk),
Exercise.objects.none(),
count=1,
similarity_scope='working',
)
self.assertEqual(
selected,
[],
'Near-identical exercise should be hard-blocked in same workout.',
)
def test_soft_similarity_blocks_adjacent_superset_repetition(self):
selector = ExerciseSelector(self.preference)
previous_set_ex = Exercise.objects.create(
name='Hip Hinge Pattern Alpha',
movement_patterns='lower pull, lower pull - hip hinge, core',
muscle_groups='glutes,hamstrings,core',
equipment_required='barbell',
is_reps=True,
is_duration=False,
is_weight=True,
is_compound=True,
difficulty_level='intermediate',
)
adjacent_candidate = Exercise.objects.create(
name='Hip Hinge Pattern Beta',
movement_patterns='lower pull - hip hinge, core',
muscle_groups='glutes,hamstrings,core',
equipment_required='barbell',
is_reps=True,
is_duration=False,
is_weight=True,
is_compound=True,
difficulty_level='intermediate',
)
selector.last_working_similarity_profiles = [
selector._build_similarity_profile(previous_set_ex)
]
selected = selector._weighted_pick(
Exercise.objects.filter(pk=adjacent_candidate.pk),
Exercise.objects.none(),
count=1,
similarity_scope='working',
)
self.assertEqual(
selected,
[],
'Very similar adjacent-set exercise should be soft-blocked.',
)
def test_dissimilar_exercise_is_allowed(self):
selector = ExerciseSelector(self.preference)
previous_set_ex = Exercise.objects.create(
name='Posterior Chain Pull Alpha',
movement_patterns='lower pull, lower pull - hip hinge, core',
muscle_groups='glutes,hamstrings,core',
equipment_required='barbell',
is_reps=True,
is_duration=False,
is_weight=True,
is_compound=True,
difficulty_level='intermediate',
)
different_candidate = Exercise.objects.create(
name='Horizontal Push Builder',
movement_patterns='upper push - horizontal, upper push',
muscle_groups='chest,triceps,deltoids',
equipment_required='dumbbell',
is_reps=True,
is_duration=False,
is_weight=True,
is_compound=True,
difficulty_level='intermediate',
)
selector.last_working_similarity_profiles = [
selector._build_similarity_profile(previous_set_ex)
]
selected = selector._weighted_pick(
Exercise.objects.filter(pk=different_candidate.pk),
Exercise.objects.none(),
count=1,
similarity_scope='working',
)
self.assertEqual(len(selected), 1)
self.assertEqual(selected[0].pk, different_candidate.pk)

View File

@@ -0,0 +1,103 @@
from django.contrib.auth.models import User
from django.test import TestCase
from exercise.models import Exercise
from generator.models import UserPreference
from generator.services.exercise_selector import ExerciseSelector
from registered_user.models import RegisteredUser
class TestModalityGuardrails(TestCase):
def setUp(self):
django_user = User.objects.create_user(
username='modality_guardrails_user',
password='testpass123',
)
registered_user = RegisteredUser.objects.create(
user=django_user,
first_name='Modality',
last_name='Guardrails',
)
self.preference = UserPreference.objects.create(
registered_user=registered_user,
days_per_week=4,
fitness_level=2,
)
def test_rep_mode_excludes_duration_only_exercises(self):
duration_only = Exercise.objects.create(
name="Dumbbell Waiter's Carry",
movement_patterns='core,core - carry',
muscle_groups='core,deltoids,upper back',
equipment_required='Dumbbell',
is_weight=True,
is_duration=True,
is_reps=False,
is_compound=True,
exercise_tier='secondary',
difficulty_level='intermediate',
complexity_rating=3,
)
reps_ex = Exercise.objects.create(
name='2 Kettlebell Clean and Press',
movement_patterns='upper push - vertical, upper push, lower pull',
muscle_groups='deltoids,triceps,glutes',
equipment_required='Kettlebell',
is_weight=True,
is_duration=False,
is_reps=True,
is_compound=True,
exercise_tier='secondary',
difficulty_level='intermediate',
complexity_rating=3,
)
selector = ExerciseSelector(self.preference)
picked = selector.select_exercises(
muscle_groups=[],
count=2,
is_duration_based=False,
)
picked_ids = {e.pk for e in picked}
self.assertIn(reps_ex.pk, picked_ids)
self.assertNotIn(duration_only.pk, picked_ids)
def test_working_selection_excludes_static_stretch_patterns(self):
static_stretch = Exercise.objects.create(
name='Supine Pec Stretch - T',
movement_patterns='mobility - static, static stretch, cool down',
muscle_groups='chest,shoulders',
equipment_required='None',
is_weight=False,
is_duration=True,
is_reps=False,
is_compound=False,
exercise_tier='accessory',
difficulty_level='beginner',
complexity_rating=1,
)
valid_reps = Exercise.objects.create(
name='Barbell Clean Pull',
movement_patterns='upper pull,hip hinge',
muscle_groups='upper back,hamstrings,glutes',
equipment_required='Barbell',
is_weight=True,
is_duration=False,
is_reps=True,
is_compound=True,
exercise_tier='primary',
difficulty_level='intermediate',
complexity_rating=3,
)
selector = ExerciseSelector(self.preference)
picked = selector.select_exercises(
muscle_groups=[],
count=2,
is_duration_based=False,
)
picked_ids = {e.pk for e in picked}
self.assertIn(valid_reps.pk, picked_ids)
self.assertNotIn(static_stretch.pk, picked_ids)

View File

@@ -61,6 +61,18 @@ class MovementEnforcementTestBase(TestCase):
superset_size_min=3,
superset_size_max=6,
)
cls.functional_type, _ = WorkoutType.objects.get_or_create(
name='functional_strength_training',
defaults={
'typical_rest_between_sets': 60,
'typical_intensity': 'high',
'rep_range_min': 6,
'rep_range_max': 12,
'duration_bias': 0.2,
'superset_size_min': 2,
'superset_size_max': 4,
},
)
cls.core_type = WorkoutType.objects.filter(name='core_training').first()
if cls.core_type is None:
cls.core_type = WorkoutType.objects.create(
@@ -184,6 +196,54 @@ class TestMovementPatternEnforcement(MovementEnforcementTestBase):
pref.delete()
def test_working_superset_filters_stretch_entries_and_keeps_positive_rest(self):
"""Working supersets should never keep static stretch entries."""
pref = self._make_preference()
gen = self._make_generator(pref)
valid = self._create_mock_exercise(
name='Barbell Clean Pull',
movement_patterns='upper pull,hip hinge',
is_duration=False,
is_reps=True,
is_weight=True,
is_compound=True,
)
stretch = self._create_mock_exercise(
name='Supine Pec Stretch - T',
movement_patterns='mobility - static, static stretch, cool down',
is_duration=True,
is_reps=False,
is_weight=False,
is_compound=False,
exercise_tier='accessory',
)
gen.exercise_selector.select_exercises.return_value = [valid, stretch]
gen.exercise_selector.balance_stretch_positions.return_value = [valid, stretch]
muscle_split = {
'muscles': ['chest', 'upper back'],
'split_type': 'upper',
'label': 'Upper',
}
wt_params = dict(WORKOUT_TYPE_DEFAULTS['functional strength'])
wt_params['num_supersets'] = (1, 1)
wt_params['exercises_per_superset'] = (2, 2)
wt_params['rounds'] = (4, 4)
supersets = gen._build_working_supersets(
muscle_split, self.functional_type, wt_params,
)
self.assertTrue(supersets, 'Expected at least one working superset.')
ss = supersets[0]
exercise_names = [e['exercise'].name for e in ss.get('exercises', [])]
self.assertIn('Barbell Clean Pull', exercise_names)
self.assertNotIn('Supine Pec Stretch - T', exercise_names)
self.assertGreater(ss.get('rest_between_rounds', 0), 0)
pref.delete()
def test_retries_when_superset_has_duplicate_focus(self):
"""Generator should retry when a working superset repeats focus family."""
pref = self._make_preference()
@@ -992,3 +1052,49 @@ class TestFinalConformance(MovementEnforcementTestBase):
self.assertIsInstance(workout, dict)
self.assertEqual(gen._build_working_supersets.call_count, 1)
pref.delete()
def test_side_pair_warning_filtered_when_final_workout_has_no_side_entries(self):
"""Do not surface side-pair warnings when final workout has no sided exercises."""
pref = self._make_preference()
gen = self._make_generator(pref)
gen._build_warmup = MagicMock(return_value=None)
gen._build_cooldown = MagicMock(return_value=None)
gen._check_quality_gates = MagicMock(return_value=[])
gen._adjust_to_time_target = MagicMock(side_effect=lambda spec, *_args, **_kwargs: spec)
ex = self._create_mock_exercise('Compound Lift', movement_patterns='upper pull')
ex.side = ''
gen._build_working_supersets = MagicMock(return_value=[
{
'name': 'Working Set 1',
'rounds': 3,
'rest_between_rounds': 45,
'modality': 'reps',
'exercises': [{'exercise': ex, 'reps': 8, 'order': 1}],
},
])
gen._get_final_conformance_violations = MagicMock(return_value=[])
gen.exercise_selector.warnings = [
'Added 2 missing opposite-side exercise partners.',
'Removed 1 unpaired side-specific exercises to enforce left/right pairing.',
'Could only find 3/5 exercises for deltoids.',
]
gen.generate_single_workout(
muscle_split={
'muscles': ['upper back'],
'split_type': 'pull',
'label': 'Pull Day',
},
workout_type=self.strength_type,
scheduled_date=date(2026, 3, 6),
)
self.assertTrue(
any('Could only find 3/5 exercises for deltoids.' in w for w in gen.warnings)
)
self.assertFalse(
any('opposite-side' in w.lower() or 'side-specific' in w.lower() for w in gen.warnings)
)
pref.delete()

View File

@@ -0,0 +1,60 @@
from django.contrib.auth.models import User
from django.test import TestCase
from exercise.models import Exercise
from generator.models import UserPreference
from generator.services.workout_generator import WorkoutGenerator
from registered_user.models import RegisteredUser
class TestRebalanceReplacementGuard(TestCase):
def setUp(self):
django_user = User.objects.create_user(
username='rebalance_guard_user',
password='testpass123',
)
registered_user = RegisteredUser.objects.create(
user=django_user,
first_name='Rebalance',
last_name='Guard',
)
self.preference = UserPreference.objects.create(
registered_user=registered_user,
days_per_week=4,
fitness_level=2,
)
def test_pull_replacement_prefers_non_sided_candidates(self):
sided_pull = Exercise.objects.create(
name='Single Arm Cable Row',
side='left_arm',
movement_patterns='upper pull - horizontal, upper pull',
muscle_groups='lats,upper back,biceps',
is_reps=True,
is_duration=False,
is_weight=True,
is_compound=False,
difficulty_level='intermediate',
)
unsided_pull = Exercise.objects.create(
name='Chest Supported Row',
side='',
movement_patterns='upper pull - horizontal, upper pull',
muscle_groups='lats,upper back,biceps',
is_reps=True,
is_duration=False,
is_weight=True,
is_compound=False,
difficulty_level='intermediate',
)
generator = WorkoutGenerator(self.preference)
replacement = generator._select_pull_replacement(
target_muscles=[],
is_duration_based=False,
prefer_weighted=False,
)
self.assertIsNotNone(replacement)
self.assertEqual(replacement.pk, unsided_pull.pk)
self.assertNotEqual(replacement.pk, sided_pull.pk)

View File

@@ -548,6 +548,89 @@ class TestValidateWorkout(TestCase):
f"Expected superset focus repetition error, got {[v.rule_id for v in violations]}",
)
def test_working_set_rejects_recovery_stretch_movements(self):
stretch_ex = _make_exercise(
name='Supine Pec Stretch - T',
movement_patterns='mobility - static, mobility, cool down',
is_reps=False,
is_duration=True,
)
push_ex = _make_exercise(
name='Single-Arm Dumbbell Push Press',
movement_patterns='upper push - vertical, upper push',
is_reps=True,
is_duration=False,
is_compound=True,
is_weight=True,
exercise_tier='secondary',
)
workout_spec = {
'supersets': [
_make_superset(name='Warm Up', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
_make_superset(
name='Working Set 1',
exercises=[
_make_entry(exercise=push_ex, reps=8, order=1),
_make_entry(exercise=stretch_ex, duration=30, order=2),
],
rounds=4,
),
_make_superset(name='Cool Down', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
],
}
violations = validate_workout(
workout_spec, 'functional_strength_training', 'general_fitness',
)
stretch_errors = [
v for v in violations
if v.rule_id == 'working_contains_recovery' and v.severity == 'error'
]
self.assertTrue(stretch_errors, 'Expected recovery/stretch error in working set.')
def test_working_set_requires_positive_rest_between_rounds(self):
workout_spec = {
'supersets': [
_make_superset(name='Warm Up', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
{
'name': 'Working Set 1',
'rounds': 4,
'rest_between_rounds': 0,
'exercises': [
_make_entry(
exercise=_make_exercise(
name='Barbell Push Press',
movement_patterns='upper push',
is_compound=True,
is_weight=True,
exercise_tier='primary',
),
reps=5,
order=1,
),
],
},
_make_superset(name='Cool Down', exercises=[
_make_entry(exercise=_make_exercise(is_reps=False), duration=30),
], rounds=1),
],
}
violations = validate_workout(
workout_spec, 'functional_strength_training', 'general_fitness',
)
rest_warnings = [
v for v in violations
if v.rule_id == 'working_rest_missing' and v.severity == 'warning'
]
self.assertTrue(rest_warnings, 'Expected warning for missing/zero working rest.')
def test_adjacent_focus_repetition_info(self):
"""Adjacent working supersets with same focus profile should be advisory."""
pull_a = _make_exercise(name='Bicep Curl', movement_patterns='upper pull')

View File

@@ -0,0 +1,203 @@
from django.contrib.auth.models import User
from django.test import TestCase
from exercise.models import Exercise
from generator.models import UserPreference
from generator.services.exercise_selector import ExerciseSelector
from registered_user.models import RegisteredUser
class TestSidePairIntegrity(TestCase):
def setUp(self):
django_user = User.objects.create_user(
username='side_pair_user',
password='testpass123',
)
registered_user = RegisteredUser.objects.create(
user=django_user,
first_name='Side',
last_name='Pair',
)
self.preference = UserPreference.objects.create(
registered_user=registered_user,
days_per_week=4,
fitness_level=2,
)
self.selector = ExerciseSelector(self.preference)
def test_orphan_left_is_removed_and_replaced(self):
left_only = Exercise.objects.create(
name='Single Arm Row Left',
side='Left',
is_reps=True,
is_duration=False,
is_weight=True,
movement_patterns='upper pull - horizontal, upper pull',
muscle_groups='lats,upper back,biceps',
difficulty_level='intermediate',
)
filler_a = Exercise.objects.create(
name='Chest Supported Row',
side='',
is_reps=True,
is_duration=False,
is_weight=True,
movement_patterns='upper pull - horizontal, upper pull',
muscle_groups='lats,upper back,biceps',
difficulty_level='intermediate',
)
filler_b = Exercise.objects.create(
name='Face Pull',
side='',
is_reps=True,
is_duration=False,
is_weight=True,
movement_patterns='upper pull, rear delt',
muscle_groups='upper back,deltoids',
difficulty_level='intermediate',
)
selected = [left_only]
base_qs = Exercise.objects.filter(pk__in=[left_only.pk, filler_a.pk, filler_b.pk])
enforced = self.selector._ensure_side_pair_integrity(selected, base_qs, count=1)
self.assertEqual(len(enforced), 1)
self.assertNotEqual(enforced[0].pk, left_only.pk)
self.assertIn(
enforced[0].pk,
{filler_a.pk, filler_b.pk},
'Orphan left-side movement should be replaced by a non-sided filler.',
)
def test_left_right_pair_is_preserved(self):
left_ex = Exercise.objects.create(
name='Single Arm Press Left',
side='Left',
is_reps=True,
is_duration=False,
is_weight=True,
movement_patterns='upper push - vertical, upper push',
muscle_groups='deltoids,triceps',
difficulty_level='intermediate',
)
right_ex = Exercise.objects.create(
name='Single Arm Press Right',
side='Right',
is_reps=True,
is_duration=False,
is_weight=True,
movement_patterns='upper push - vertical, upper push',
muscle_groups='deltoids,triceps',
difficulty_level='intermediate',
)
enforced = self.selector._ensure_side_pair_integrity(
[left_ex, right_ex],
Exercise.objects.filter(pk__in=[left_ex.pk, right_ex.pk]),
count=2,
)
enforced_ids = {ex.pk for ex in enforced}
self.assertEqual(enforced_ids, {left_ex.pk, right_ex.pk})
def test_left_arm_right_arm_pair_is_preserved(self):
left_ex = Exercise.objects.create(
name='Single Arm Row',
side='left_arm',
is_reps=True,
is_duration=False,
is_weight=True,
movement_patterns='upper pull - horizontal, upper pull',
muscle_groups='lats,upper back,biceps',
difficulty_level='intermediate',
)
right_ex = Exercise.objects.create(
name='Single Arm Row',
side='right_arm',
is_reps=True,
is_duration=False,
is_weight=True,
movement_patterns='upper pull - horizontal, upper pull',
muscle_groups='lats,upper back,biceps',
difficulty_level='intermediate',
)
paired = self.selector._pair_sided_exercises(
[left_ex],
Exercise.objects.filter(pk__in=[left_ex.pk, right_ex.pk]),
)
paired_ids = {ex.pk for ex in paired}
self.assertEqual(paired_ids, {left_ex.pk, right_ex.pk})
def test_orphan_left_arm_is_removed(self):
left_ex = Exercise.objects.create(
name='Single Arm Row',
side='left_arm',
is_reps=True,
is_duration=False,
is_weight=True,
movement_patterns='upper pull - horizontal, upper pull',
muscle_groups='lats,upper back,biceps',
difficulty_level='intermediate',
)
filler = Exercise.objects.create(
name='Inverted Row',
side='',
is_reps=True,
is_duration=False,
is_weight=False,
movement_patterns='upper pull - horizontal, upper pull',
muscle_groups='lats,upper back,biceps',
difficulty_level='intermediate',
)
enforced = self.selector._ensure_side_pair_integrity(
[left_ex],
Exercise.objects.filter(pk__in=[left_ex.pk, filler.pk]),
count=1,
)
self.assertEqual(len(enforced), 1)
self.assertEqual(enforced[0].pk, filler.pk)
def test_try_hard_fetch_adds_opposite_side_partner_from_global_db(self):
left_ex = Exercise.objects.create(
name='Single Arm Lateral Raise Left',
side='Left',
is_reps=True,
is_duration=False,
is_weight=True,
movement_patterns='upper push',
muscle_groups='deltoids',
difficulty_level='intermediate',
)
right_ex = Exercise.objects.create(
name='Single Arm Lateral Raise Right',
side='Right',
is_reps=True,
is_duration=False,
is_weight=True,
movement_patterns='upper push',
muscle_groups='deltoids',
difficulty_level='intermediate',
)
filler = Exercise.objects.create(
name='Shoulder Tap',
side='',
is_reps=True,
is_duration=False,
is_weight=False,
movement_patterns='upper push',
muscle_groups='deltoids,core',
difficulty_level='intermediate',
)
# base_qs intentionally does not include right_ex to validate global fallback.
base_qs = Exercise.objects.filter(pk__in=[left_ex.pk, filler.pk])
enforced = self.selector._ensure_side_pair_integrity(
[left_ex, filler],
base_qs,
count=2,
)
enforced_ids = {ex.pk for ex in enforced}
self.assertIn(left_ex.pk, enforced_ids)
self.assertIn(right_ex.pk, enforced_ids)
self.assertNotIn(filler.pk, enforced_ids)

View File

@@ -85,8 +85,8 @@ class TestWarmupSelector(TestCase):
self.assertNotIn(weighted_press.pk, selected_ids)
self.assertNotIn(duration_push.pk, selected_ids)
def test_warmup_excludes_side_specific_variants(self):
left_variant = Exercise.objects.create(
def test_warmup_keeps_side_specific_variants_adjacent(self):
left_variant_a = Exercise.objects.create(
name='Side Lying T Stretch',
side='left_arm',
movement_patterns='dynamic stretch, mobility - dynamic, warm up',
@@ -99,7 +99,7 @@ class TestWarmupSelector(TestCase):
complexity_rating=1,
difficulty_level='beginner',
)
right_variant = Exercise.objects.create(
right_variant_a = Exercise.objects.create(
name='Side Lying T Stretch',
side='right_arm',
movement_patterns='dynamic stretch, mobility - dynamic, warm up',
@@ -112,9 +112,9 @@ class TestWarmupSelector(TestCase):
complexity_rating=1,
difficulty_level='beginner',
)
unsided_a = Exercise.objects.create(
name='Worlds Greatest Stretch',
side='',
left_variant_b = Exercise.objects.create(
name='Quadruped Adductor Stretch with Thoracic Rotation',
side='left_side',
movement_patterns='dynamic stretch, mobility - dynamic, warm up',
is_duration=True,
is_reps=False,
@@ -125,31 +125,47 @@ class TestWarmupSelector(TestCase):
complexity_rating=1,
difficulty_level='beginner',
)
unsided_b = Exercise.objects.create(
name='Hip Opener Flow',
side='',
movement_patterns='activation, warmup, cardio/locomotion',
right_variant_b = Exercise.objects.create(
name='Quadruped Adductor Stretch with Thoracic Rotation',
side='right_side',
movement_patterns='dynamic stretch, mobility - dynamic, warm up',
is_duration=True,
is_reps=False,
is_weight=False,
is_compound=False,
exercise_tier='accessory',
hr_elevation_rating=3,
complexity_rating=2,
hr_elevation_rating=2,
complexity_rating=1,
difficulty_level='beginner',
)
selector = ExerciseSelector(self.preference)
selected = selector.select_warmup_exercises(target_muscles=[], count=2)
selected_ids = {ex.pk for ex in selected}
selected = selector.select_warmup_exercises(target_muscles=[], count=4)
self.assertEqual(selected_ids, {unsided_a.pk, unsided_b.pk})
self.assertNotIn(left_variant.pk, selected_ids)
self.assertNotIn(right_variant.pk, selected_ids)
self.assertTrue(all(not (ex.side or '').strip() for ex in selected))
selected_ids = [ex.pk for ex in selected]
self.assertEqual(
set(selected_ids),
{left_variant_a.pk, right_variant_a.pk, left_variant_b.pk, right_variant_b.pk},
)
def test_cooldown_excludes_side_specific_variants(self):
left_variant = Exercise.objects.create(
side_pairs = {}
for idx, ex in enumerate(selected):
key = selector._strip_side_tokens(ex.name)
side_pairs.setdefault(key, []).append(idx)
self.assertEqual(len(side_pairs['side lying t stretch']), 2)
self.assertEqual(len(side_pairs['quadruped adductor stretch with thoracic rotation']), 2)
self.assertEqual(
side_pairs['side lying t stretch'][1],
side_pairs['side lying t stretch'][0] + 1,
)
self.assertEqual(
side_pairs['quadruped adductor stretch with thoracic rotation'][1],
side_pairs['quadruped adductor stretch with thoracic rotation'][0] + 1,
)
def test_cooldown_keeps_side_specific_variants_adjacent(self):
left_variant_a = Exercise.objects.create(
name="Matsyendra's Pose",
side='left_side',
movement_patterns='static stretch, cool down',
@@ -162,7 +178,7 @@ class TestWarmupSelector(TestCase):
complexity_rating=1,
difficulty_level='beginner',
)
right_variant = Exercise.objects.create(
right_variant_a = Exercise.objects.create(
name="Matsyendra's Pose",
side='right_side',
movement_patterns='static stretch, cool down',
@@ -175,9 +191,9 @@ class TestWarmupSelector(TestCase):
complexity_rating=1,
difficulty_level='beginner',
)
unsided_a = Exercise.objects.create(
name='Butterfly Stretch',
side='',
left_variant_b = Exercise.objects.create(
name='Miniband Reverse Clamshell',
side='left_leg',
movement_patterns='mobility - static, cooldown',
is_duration=True,
is_reps=False,
@@ -188,25 +204,41 @@ class TestWarmupSelector(TestCase):
complexity_rating=1,
difficulty_level='beginner',
)
unsided_b = Exercise.objects.create(
name='Hamstring Reach',
side='',
movement_patterns='static stretch, cool down',
right_variant_b = Exercise.objects.create(
name='Miniband Reverse Clamshell',
side='right_leg',
movement_patterns='mobility - static, cooldown',
is_duration=True,
is_reps=False,
is_weight=False,
is_compound=False,
exercise_tier='accessory',
hr_elevation_rating=2,
hr_elevation_rating=1,
complexity_rating=1,
difficulty_level='beginner',
)
selector = ExerciseSelector(self.preference)
selected = selector.select_cooldown_exercises(target_muscles=[], count=2)
selected_ids = {ex.pk for ex in selected}
selected = selector.select_cooldown_exercises(target_muscles=[], count=4)
self.assertEqual(selected_ids, {unsided_a.pk, unsided_b.pk})
self.assertNotIn(left_variant.pk, selected_ids)
self.assertNotIn(right_variant.pk, selected_ids)
self.assertTrue(all(not (ex.side or '').strip() for ex in selected))
selected_ids = [ex.pk for ex in selected]
self.assertEqual(
set(selected_ids),
{left_variant_a.pk, right_variant_a.pk, left_variant_b.pk, right_variant_b.pk},
)
side_pairs = {}
for idx, ex in enumerate(selected):
key = selector._strip_side_tokens(ex.name)
side_pairs.setdefault(key, []).append(idx)
self.assertEqual(len(side_pairs["matsyendra's pose"]), 2)
self.assertEqual(len(side_pairs['miniband reverse clamshell']), 2)
self.assertEqual(
side_pairs["matsyendra's pose"][1],
side_pairs["matsyendra's pose"][0] + 1,
)
self.assertEqual(
side_pairs['miniband reverse clamshell'][1],
side_pairs['miniband reverse clamshell'][0] + 1,
)

View File

@@ -0,0 +1,136 @@
from django.test import SimpleTestCase
from generator.services.workout_generation.entry_rules import (
apply_rep_volume_floor,
pick_reps_for_exercise,
working_rest_seconds,
)
from generator.services.workout_generation.focus import (
focus_key_for_exercise,
has_duplicate_focus,
)
from generator.services.workout_generation.modality import (
clamp_duration_bias,
plan_superset_modalities,
)
from generator.services.workout_generation.pattern_planning import (
merge_pattern_preferences,
rotated_muscle_subset,
working_position_label,
)
from generator.services.workout_generation.recovery import is_recovery_exercise
from generator.services.workout_generation.scaling import apply_fitness_scaling
from generator.services.workout_generation.section_builders import (
build_duration_entries,
build_section_superset,
section_exercise_count,
)
class _Rng:
def __init__(self, randint_values=None):
self._randint_values = list(randint_values or [])
def randint(self, low, high):
if self._randint_values:
return self._randint_values.pop(0)
return low
def shuffle(self, arr):
# Deterministic for tests.
return None
class _Ex:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class TestWorkoutGenerationModules(SimpleTestCase):
def test_section_count_and_duration_entries(self):
rng = _Rng([6, 27, 31])
self.assertEqual(section_exercise_count('warmup', 1, rng=rng), 6)
exercises = [_Ex(name='A'), _Ex(name='B')]
entries = build_duration_entries(
exercises,
duration_min=20,
duration_max=40,
min_duration=20,
duration_multiple=5,
rng=rng,
)
self.assertEqual(entries[0]['duration'], 25)
self.assertEqual(entries[1]['duration'], 30)
section = build_section_superset('Warm Up', entries)
self.assertEqual(section['name'], 'Warm Up')
self.assertEqual(section['rounds'], 1)
def test_scaling_and_rest_floor(self):
params = {
'rep_min': 4,
'rep_max': 10,
'rounds': (3, 4),
'rest_between_rounds': 60,
}
scaling = {
1: {'rep_min_mult': 1.1, 'rep_max_mult': 1.2, 'rounds_adj': -1, 'rest_adj': 15},
2: {'rep_min_mult': 1.0, 'rep_max_mult': 1.0, 'rounds_adj': 0, 'rest_adj': 0},
}
out = apply_fitness_scaling(
params,
fitness_level=1,
scaling_config=scaling,
min_reps=6,
min_reps_strength=1,
is_strength=True,
)
self.assertGreaterEqual(out['rep_min'], 5)
self.assertEqual(working_rest_seconds(-5, 0), 15)
def test_modality_helpers(self):
self.assertEqual(clamp_duration_bias(0.9, (0.2, 0.6)), 0.6)
modalities = plan_superset_modalities(
num_supersets=4,
duration_bias=0.5,
duration_bias_range=(0.25, 0.5),
is_strength_workout=False,
rng=_Rng(),
)
self.assertEqual(len(modalities), 4)
self.assertTrue(any(modalities))
def test_pattern_and_focus_helpers(self):
self.assertEqual(working_position_label(0, 3), 'early')
self.assertEqual(working_position_label(1, 3), 'middle')
self.assertEqual(working_position_label(2, 3), 'late')
self.assertEqual(
merge_pattern_preferences(['upper pull', 'core'], ['core', 'lunge']),
['core'],
)
self.assertEqual(
rotated_muscle_subset(['a', 'b', 'c'], 1),
['b', 'c', 'a'],
)
curl_a = _Ex(name='Alternating Bicep Curls', movement_patterns='upper pull')
curl_b = _Ex(name='Bicep Curls', movement_patterns='upper pull')
self.assertEqual(focus_key_for_exercise(curl_a), 'bicep_curl')
self.assertTrue(has_duplicate_focus([curl_a, curl_b]))
def test_recovery_and_rep_selection(self):
stretch = _Ex(name='Supine Pec Stretch - T', movement_patterns='mobility - static')
self.assertTrue(is_recovery_exercise(stretch))
ex = _Ex(exercise_tier='primary')
reps = pick_reps_for_exercise(
ex,
{'rep_min': 8, 'rep_max': 12},
{'primary': (3, 6)},
rng=_Rng([5]),
)
self.assertEqual(reps, 5)
entries = [{'reps': 3}, {'duration': 30}]
apply_rep_volume_floor(entries, rounds=3, min_volume=12)
self.assertEqual(entries[0]['reps'], 4)

View File

@@ -1,6 +1,8 @@
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,
@@ -44,6 +46,19 @@ from .serializers import (
)
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
@@ -67,7 +82,7 @@ def generation_rules(request):
@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)
registered_user = get_registered_user(request)
preference, _created = UserPreference.objects.get_or_create(
registered_user=registered_user,
)
@@ -80,7 +95,7 @@ def get_preferences(request):
@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)
registered_user = get_registered_user(request)
preference, _created = UserPreference.objects.get_or_create(
registered_user=registered_user,
)
@@ -109,7 +124,7 @@ def generate_plan(request):
Generate a weekly workout plan.
Body: {"week_start_date": "YYYY-MM-DD"}
"""
registered_user = RegisteredUser.objects.get(user=request.user)
registered_user = get_registered_user(request)
week_start_date_str = request.data.get('week_start_date')
if not week_start_date_str:
@@ -191,8 +206,9 @@ def generate_plan(request):
generation_warnings = generator.warnings
except Exception as e:
logger.exception("Unexpected error in generate_plan")
return Response(
{'error': f'Plan generation failed: {str(e)}'},
{"error": "An unexpected error occurred. Please try again."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -212,9 +228,11 @@ def generate_plan(request):
@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)
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',
@@ -228,9 +246,11 @@ def list_plans(request):
@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)
registered_user = get_registered_user(request)
plan = get_object_or_404(
GeneratedWeeklyPlan.objects.prefetch_related(
GeneratedWeeklyPlan.objects.select_related(
'registered_user',
).prefetch_related(
'generated_workouts__workout_type',
'generated_workouts__workout',
),
@@ -253,9 +273,9 @@ 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)
registered_user = get_registered_user(request)
generated_workout = get_object_or_404(
GeneratedWorkout,
GeneratedWorkout.objects.select_related('workout', 'workout_type'),
pk=workout_id,
plan__registered_user=registered_user,
)
@@ -298,9 +318,9 @@ def reject_workout(request, workout_id):
Reject a generated workout with optional feedback.
Body: {"feedback": "..."}
"""
registered_user = RegisteredUser.objects.get(user=request.user)
registered_user = get_registered_user(request)
generated_workout = get_object_or_404(
GeneratedWorkout,
GeneratedWorkout.objects.select_related('workout', 'workout_type'),
pk=workout_id,
plan__registered_user=registered_user,
)
@@ -328,9 +348,9 @@ 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)
registered_user = get_registered_user(request)
generated_workout = get_object_or_404(
GeneratedWorkout,
GeneratedWorkout.objects.select_related('workout', 'workout_type'),
pk=workout_id,
plan__registered_user=registered_user,
)
@@ -379,9 +399,9 @@ 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)
registered_user = get_registered_user(request)
generated_workout = get_object_or_404(
GeneratedWorkout,
GeneratedWorkout.objects.select_related('workout', 'workout_type', 'plan'),
pk=workout_id,
plan__registered_user=registered_user,
)
@@ -413,20 +433,15 @@ def regenerate_workout(request, workout_id):
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)
)
# 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)
@@ -489,8 +504,9 @@ def regenerate_workout(request, workout_id):
cache.delete(f"plan{generated_workout.plan_id}")
except Exception as e:
logger.exception("Unexpected error in regenerate_workout")
return Response(
{'error': f'Regeneration failed: {str(e)}'},
{"error": "An unexpected error occurred. Please try again."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -510,9 +526,9 @@ 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)
registered_user = get_registered_user(request)
generated_workout = get_object_or_404(
GeneratedWorkout,
GeneratedWorkout.objects.select_related('workout'),
pk=workout_id,
plan__registered_user=registered_user,
)
@@ -545,7 +561,7 @@ def delete_workout_day(request, workout_id):
@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)
registered_user = get_registered_user(request)
superset = get_object_or_404(Superset, pk=superset_id)
# Verify ownership through the workout
@@ -565,11 +581,14 @@ def delete_superset(request, superset_id):
# 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')
# 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
ss.save()
if remaining:
Superset.objects.bulk_update(remaining, ['order'])
return Response({'status': 'deleted'}, status=status.HTTP_200_OK)
@@ -579,7 +598,7 @@ def delete_superset(request, superset_id):
@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)
registered_user = get_registered_user(request)
superset_exercise = get_object_or_404(SupersetExercise, pk=exercise_id)
# Verify ownership
@@ -600,11 +619,14 @@ def delete_superset_exercise(request, exercise_id):
# 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')
# 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
se.save()
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:
@@ -653,7 +675,7 @@ 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)
registered_user = get_registered_user(request)
superset_exercise = get_object_or_404(SupersetExercise, pk=exercise_id)
# Verify ownership
@@ -734,7 +756,7 @@ def analysis_stats(request):
"""
muscle_splits = MuscleGroupSplit.objects.all()
weekly_patterns = WeeklySplitPattern.objects.all()
structure_rules = WorkoutStructureRule.objects.all()
structure_rules = WorkoutStructureRule.objects.select_related('workout_type').all()
movement_orders = MovementPatternOrder.objects.all()
data = {
@@ -778,29 +800,35 @@ 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)
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)
for gw in workouts:
if gw.is_rest_day or not gw.workout:
continue
gw.status = 'accepted'
gw.save()
workouts = GeneratedWorkout.objects.filter(plan=plan).select_related('workout')
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,
)
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)
@@ -815,10 +843,10 @@ def confirm_plan(request, plan_id):
@permission_classes([IsAuthenticated])
def preview_plan(request):
"""
Generate a weekly plan preview. Returns JSON nothing is saved to DB.
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)
registered_user = get_registered_user(request)
week_start_date_str = request.data.get('week_start_date')
if not week_start_date_str:
@@ -872,8 +900,9 @@ def preview_plan(request):
)
preview = generator.generate_weekly_preview(week_start_date)
except Exception as e:
logger.exception("Unexpected error in preview_plan")
return Response(
{'error': f'Preview generation failed: {str(e)}'},
{"error": "An unexpected error occurred. Please try again."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -885,7 +914,7 @@ def preview_plan(request):
@permission_classes([IsAuthenticated])
def preview_day(request):
"""
Generate a single day preview. Returns JSON nothing is saved to DB.
Generate a single day preview. Returns JSON -- nothing is saved to DB.
Body: {
"target_muscles": ["chest", "shoulders"],
"focus_area": "Upper Push",
@@ -893,7 +922,7 @@ def preview_day(request):
"date": "2026-02-09"
}
"""
registered_user = RegisteredUser.objects.get(user=request.user)
registered_user = get_registered_user(request)
date_str = request.data.get('date')
if not date_str:
@@ -954,26 +983,19 @@ def preview_day(request):
generator = WorkoutGenerator(preference)
# If plan_id is provided, exclude sibling workout exercises
# 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_workouts = GeneratedWorkout.objects.filter(
plan=plan,
is_rest_day=False,
workout__isnull=False,
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)
)
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:
@@ -987,8 +1009,9 @@ def preview_day(request):
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': f'Day preview generation failed: {str(e)}'},
{"error": "An unexpected error occurred. Please try again."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -1003,7 +1026,7 @@ 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)
registered_user = get_registered_user(request)
week_start_date_str = request.data.get('week_start_date')
days = request.data.get('days', [])
@@ -1057,105 +1080,130 @@ def save_plan(request):
),
}
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,
)
# Prefetch all exercise IDs referenced in the plan to avoid N+1 queries
all_exercise_ids = []
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=[],
)
if day_data.get('is_rest_day', False):
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 ss_data in workout_spec_data.get('supersets', []):
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
if exercise_id:
all_exercise_ids.append(exercise_id)
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),
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,
})
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_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)
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,
)
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,
)
# 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': f'Save failed: {str(e)}'},
{"error": "An unexpected error occurred. Please try again."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)