- Add rules_engine.py with quantitative rules for all 8 workout types - Add quality gate retry loop in generate_single_workout() - Expand calibrate_structure_rules to all 120 combinations (8 types × 5 goals × 3 sections) - Wire WeeklySplitPattern DB records into _pick_weekly_split() - Enforce movement patterns from WorkoutStructureRule in exercise selection - Add straight-set strength support (single main lift, 4-6 rounds) - Add modality consistency check for duration-dominant workout types - Add InjuryStep component to onboarding and preferences - Add sibling exercise exclusion in regenerate and preview_day endpoints - Display generator warnings on dashboard - Expand fix_rep_durations, fix_exercise_flags, fix_movement_pattern_typo - Add audit_exercise_data and check_rules_drift management commands - Add Next.js frontend with dashboard, onboarding, preferences, history pages - Add generator app with ML-powered workout generation pipeline - 96 new tests across 7 test modules Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
223 lines
7.7 KiB
Python
223 lines
7.7 KiB
Python
"""
|
|
Fix exercise flags and assign missing muscle associations.
|
|
|
|
1. Fix is_weight flags on exercises that are bodyweight but incorrectly marked
|
|
is_weight=True (wall sits, agility ladder, planks, bodyweight exercises, etc.)
|
|
|
|
2. Assign muscle groups to exercises that have no ExerciseMuscle rows, using
|
|
name keyword matching.
|
|
|
|
Known false positives: wall sits, agility ladder, planks, body weight exercises,
|
|
and similar movements that use no external resistance.
|
|
|
|
Usage:
|
|
python manage.py fix_exercise_flags
|
|
python manage.py fix_exercise_flags --dry-run
|
|
"""
|
|
|
|
import re
|
|
from django.core.management.base import BaseCommand
|
|
from exercise.models import Exercise
|
|
from muscle.models import Muscle, ExerciseMuscle
|
|
|
|
try:
|
|
from equipment.models import WorkoutEquipment
|
|
except ImportError:
|
|
WorkoutEquipment = None
|
|
|
|
|
|
# Patterns that indicate bodyweight exercises (no external weight).
|
|
# Uses word boundary matching to avoid substring issues (e.g. "l sit" in "wall sit").
|
|
BODYWEIGHT_PATTERNS = [
|
|
r'\bwall sit\b',
|
|
r'\bplank\b',
|
|
r'\bmountain climber\b',
|
|
r'\bburpee\b',
|
|
r'\bpush ?up\b',
|
|
r'\bpushup\b',
|
|
r'\bpull ?up\b',
|
|
r'\bpullup\b',
|
|
r'\bchin ?up\b',
|
|
r'\bchinup\b',
|
|
r'\bdips?\b',
|
|
r'\bpike\b',
|
|
r'\bhandstand\b',
|
|
r'\bl sit\b',
|
|
r'\bv sit\b',
|
|
r'\bhollow\b',
|
|
r'\bsuperman\b',
|
|
r'\bbird dog\b',
|
|
r'\bdead bug\b',
|
|
r'\bbear crawl\b',
|
|
r'\bcrab walk\b',
|
|
r'\binchworm\b',
|
|
r'\bjumping jack\b',
|
|
r'\bhigh knee\b',
|
|
r'\bbutt kick\b',
|
|
r'\bskater\b',
|
|
r'\blunge jump\b',
|
|
r'\bjump lunge\b',
|
|
r'\bsquat jump\b',
|
|
r'\bjump squat\b',
|
|
r'\bbox jump\b',
|
|
r'\btuck jump\b',
|
|
r'\bbroad jump\b',
|
|
r'\bsprinter\b',
|
|
r'\bagility ladder\b',
|
|
r'\bbody ?weight\b',
|
|
r'\bbodyweight\b',
|
|
r'\bcalisthenics?\b',
|
|
r'\bflutter kick\b',
|
|
r'\bleg raise\b',
|
|
r'\bsit ?up\b',
|
|
r'\bcrunch\b',
|
|
r'\bstretch\b',
|
|
r'\byoga\b',
|
|
r'\bfoam roll\b',
|
|
r'\bjump rope\b',
|
|
r'\bspider crawl\b',
|
|
]
|
|
|
|
# Keywords for assigning muscles to exercises with no ExerciseMuscle rows.
|
|
# Each muscle name maps to a list of name keywords to match against exercise name.
|
|
EXERCISE_MUSCLE_KEYWORDS = {
|
|
'chest': ['chest', 'pec', 'bench press', 'push up', 'fly'],
|
|
'back': ['back', 'lat', 'row', 'pull up', 'pulldown'],
|
|
'shoulders': ['shoulder', 'delt', 'press', 'raise', 'shrug'],
|
|
'quads': ['quad', 'squat', 'leg press', 'lunge', 'extension'],
|
|
'hamstrings': ['hamstring', 'curl', 'deadlift', 'rdl'],
|
|
'glutes': ['glute', 'hip thrust', 'bridge'],
|
|
'biceps': ['bicep', 'curl'],
|
|
'triceps': ['tricep', 'pushdown', 'extension', 'dip'],
|
|
'core': ['core', 'ab', 'crunch', 'plank', 'sit up'],
|
|
'calves': ['calf', 'calves', 'calf raise'],
|
|
}
|
|
|
|
|
|
class Command(BaseCommand):
|
|
help = 'Fix is_weight flags and assign missing muscle associations'
|
|
|
|
def add_arguments(self, parser):
|
|
parser.add_argument(
|
|
'--dry-run',
|
|
action='store_true',
|
|
help='Show what would change without writing to DB',
|
|
)
|
|
|
|
def handle(self, *args, **options):
|
|
dry_run = options['dry_run']
|
|
|
|
self.stdout.write(self.style.MIGRATE_HEADING('Step 1: Fix is_weight false positives'))
|
|
weight_fixed = self._fix_is_weight_false_positives(dry_run)
|
|
|
|
self.stdout.write(self.style.MIGRATE_HEADING('\nStep 2: Assign missing muscles'))
|
|
muscle_assigned = self._assign_missing_muscles(dry_run)
|
|
|
|
prefix = '[DRY RUN] ' if dry_run else ''
|
|
self.stdout.write(self.style.SUCCESS(
|
|
f'\n{prefix}Summary: Fixed {weight_fixed} is_weight flags, '
|
|
f'assigned muscles to {muscle_assigned} exercises'
|
|
))
|
|
|
|
def _fix_is_weight_false_positives(self, dry_run):
|
|
"""Fix exercises that are bodyweight but incorrectly marked is_weight=True."""
|
|
# Get exercises that have is_weight=True
|
|
weighted_exercises = Exercise.objects.filter(is_weight=True)
|
|
|
|
# Get exercises that have equipment assigned (if WorkoutEquipment exists)
|
|
exercises_with_equipment = set()
|
|
if WorkoutEquipment is not None:
|
|
exercises_with_equipment = set(
|
|
WorkoutEquipment.objects.values_list('exercise_id', flat=True).distinct()
|
|
)
|
|
|
|
fixed = 0
|
|
for ex in weighted_exercises:
|
|
if not ex.name:
|
|
continue
|
|
|
|
name_lower = ex.name.lower()
|
|
|
|
# Check if name matches any bodyweight pattern
|
|
is_bodyweight_name = any(
|
|
re.search(pat, name_lower) for pat in BODYWEIGHT_PATTERNS
|
|
)
|
|
|
|
# Also check if the exercise has no equipment assigned
|
|
has_no_equipment = ex.pk not in exercises_with_equipment
|
|
|
|
if is_bodyweight_name and has_no_equipment:
|
|
if dry_run:
|
|
self.stdout.write(f' Would fix: {ex.name} (id={ex.pk})')
|
|
else:
|
|
ex.is_weight = False
|
|
ex.save(update_fields=['is_weight'])
|
|
self.stdout.write(f' Fixed: {ex.name} (id={ex.pk})')
|
|
fixed += 1
|
|
|
|
prefix = '[DRY RUN] ' if dry_run else ''
|
|
self.stdout.write(self.style.SUCCESS(
|
|
f'{prefix}Fixed {fixed} exercises from is_weight=True to is_weight=False'
|
|
))
|
|
return fixed
|
|
|
|
def _assign_missing_muscles(self, dry_run):
|
|
"""Assign muscle groups to exercises that have no ExerciseMuscle rows."""
|
|
# Find exercises with no muscle associations
|
|
exercises_with_muscles = set(
|
|
ExerciseMuscle.objects.values_list('exercise_id', flat=True).distinct()
|
|
)
|
|
orphan_exercises = Exercise.objects.exclude(pk__in=exercises_with_muscles)
|
|
|
|
if not orphan_exercises.exists():
|
|
self.stdout.write(' No exercises without muscle assignments found.')
|
|
return 0
|
|
|
|
self.stdout.write(f' Found {orphan_exercises.count()} exercises without muscle assignments')
|
|
|
|
# Build a cache of muscle objects by name (case-insensitive)
|
|
muscle_cache = {}
|
|
for muscle in Muscle.objects.all():
|
|
muscle_cache[muscle.name.lower()] = muscle
|
|
|
|
assigned_count = 0
|
|
for ex in orphan_exercises:
|
|
if not ex.name:
|
|
continue
|
|
|
|
name_lower = ex.name.lower()
|
|
matched_muscles = []
|
|
|
|
for muscle_name, keywords in EXERCISE_MUSCLE_KEYWORDS.items():
|
|
for keyword in keywords:
|
|
if keyword in name_lower:
|
|
# Find the muscle in the cache
|
|
muscle_obj = muscle_cache.get(muscle_name)
|
|
if muscle_obj and muscle_obj not in matched_muscles:
|
|
matched_muscles.append(muscle_obj)
|
|
break # One keyword match per muscle group is enough
|
|
|
|
if matched_muscles:
|
|
if dry_run:
|
|
muscle_names = ', '.join(m.name for m in matched_muscles)
|
|
self.stdout.write(
|
|
f' Would assign: {ex.name} (id={ex.pk}) -> [{muscle_names}]'
|
|
)
|
|
else:
|
|
for muscle_obj in matched_muscles:
|
|
ExerciseMuscle.objects.get_or_create(
|
|
exercise=ex,
|
|
muscle=muscle_obj,
|
|
)
|
|
muscle_names = ', '.join(m.name for m in matched_muscles)
|
|
self.stdout.write(
|
|
f' Assigned: {ex.name} (id={ex.pk}) -> [{muscle_names}]'
|
|
)
|
|
assigned_count += 1
|
|
|
|
prefix = '[DRY RUN] ' if dry_run else ''
|
|
self.stdout.write(self.style.SUCCESS(
|
|
f'{prefix}Assigned muscles to {assigned_count} exercises'
|
|
))
|
|
return assigned_count
|