""" 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.' ))