- 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>
131 lines
5.1 KiB
Python
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.'
|
|
))
|