Files
WerkoutAPI/generator/management/commands/normalize_muscles.py
Trey t 1c61b80731 workout generator audit: rules engine, structure rules, split patterns, injury UX, metadata cleanup
- 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>
2026-02-22 20:07:40 -06:00

131 lines
5.1 KiB
Python

"""
Management command to normalize muscle names in the database.
Fixes casing duplicates (e.g. "Quads" vs "quads") and updates
ExerciseMuscle records to point to the canonical muscle entries.
Usage:
python manage.py normalize_muscles # apply changes
python manage.py normalize_muscles --dry-run # preview only
"""
from django.core.management.base import BaseCommand
from muscle.models import Muscle, ExerciseMuscle
from generator.services.muscle_normalizer import normalize_muscle_name
class Command(BaseCommand):
help = 'Normalize muscle names (fix casing duplicates) and consolidate ExerciseMuscle records.'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Preview changes without modifying the database.',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN - no changes will be made.\n'))
muscles = Muscle.objects.all().order_by('name')
self.stdout.write(f'Total muscles in DB: {muscles.count()}\n')
# Build a mapping: canonical_name -> list of Muscle objects with that canonical name
canonical_map = {}
for m in muscles:
canonical = normalize_muscle_name(m.name)
if canonical is None:
canonical = m.name.strip().lower()
canonical_map.setdefault(canonical, []).append(m)
# Identify duplicates (canonical names with > 1 Muscle record)
duplicates = {k: v for k, v in canonical_map.items() if len(v) > 1}
if not duplicates:
self.stdout.write(self.style.SUCCESS('No duplicate muscles found. Nothing to normalize.'))
return
self.stdout.write(f'Found {len(duplicates)} canonical names with duplicates:\n')
merged_count = 0
reassigned_count = 0
for canonical, muscle_list in sorted(duplicates.items()):
names = [m.name for m in muscle_list]
self.stdout.write(f'\n "{canonical}" <- {names}')
# Keep the first one (or the one whose name already matches canonical)
keep = None
for m in muscle_list:
if m.name == canonical:
keep = m
break
if keep is None:
keep = muscle_list[0]
to_merge = [m for m in muscle_list if m.pk != keep.pk]
for old_muscle in to_merge:
# Reassign ExerciseMuscle records from old_muscle to keep
em_records = ExerciseMuscle.objects.filter(muscle=old_muscle)
count = em_records.count()
if count > 0:
self.stdout.write(f' Reassigning {count} ExerciseMuscle records: '
f'"{old_muscle.name}" (id={old_muscle.pk}) -> '
f'"{keep.name}" (id={keep.pk})')
if not dry_run:
# Check for conflicts (same exercise already linked to keep)
for em in em_records:
existing = ExerciseMuscle.objects.filter(
exercise=em.exercise, muscle=keep
).exists()
if existing:
em.delete()
else:
em.muscle = keep
em.save()
reassigned_count += count
# Rename keep to canonical if needed
if keep.name != canonical and not dry_run:
keep.name = canonical
keep.save()
# Delete the duplicate
self.stdout.write(f' Deleting duplicate: "{old_muscle.name}" (id={old_muscle.pk})')
if not dry_run:
old_muscle.delete()
merged_count += 1
# Also fix names that aren't duplicates but have wrong casing
rename_count = 0
for canonical, muscle_list in canonical_map.items():
if len(muscle_list) == 1:
m = muscle_list[0]
if m.name != canonical:
self.stdout.write(f'\n Renaming: "{m.name}" -> "{canonical}"')
if not dry_run:
m.name = canonical
m.save()
rename_count += 1
self.stdout.write('\n')
if dry_run:
self.stdout.write(self.style.WARNING(
f'DRY RUN complete. Would merge {merged_count} duplicates, '
f'reassign {reassigned_count} ExerciseMuscle records, '
f'rename {rename_count} muscles.'
))
else:
remaining = Muscle.objects.count()
self.stdout.write(self.style.SUCCESS(
f'Done. Merged {merged_count} duplicates, '
f'reassigned {reassigned_count} ExerciseMuscle records, '
f'renamed {rename_count} muscles. '
f'{remaining} muscles remaining.'
))