""" Classifies all Exercise records by difficulty_level and complexity_rating using name-based keyword matching and movement_patterns fallback rules. difficulty_level: 'beginner', 'intermediate', 'advanced' complexity_rating: 1-5 integer scale Classification strategy (applied in order, first match wins): 1. **Name-based keyword rules** -- regex patterns matched against exercise.name - ADVANCED_NAME_PATTERNS -> 'advanced' - BEGINNER_NAME_PATTERNS -> 'beginner' - Unmatched -> 'intermediate' (default) 2. **Name-based complexity rules** -- regex patterns matched against exercise.name - COMPLEXITY_5_PATTERNS -> 5 (Olympic lifts, advanced gymnastics) - COMPLEXITY_4_PATTERNS -> 4 (complex multi-joint, unilateral loaded) - COMPLEXITY_1_PATTERNS -> 1 (single-joint isolation, simple stretches) - COMPLEXITY_2_PATTERNS -> 2 (basic compound or standard bodyweight) - Unmatched -> movement_patterns fallback -> default 3 3. **Movement-pattern fallback** for exercises not caught by name rules, using the exercise's movement_patterns CharField. Usage: python manage.py classify_exercises python manage.py classify_exercises --dry-run python manage.py classify_exercises --dry-run --verbose """ import re from django.core.management.base import BaseCommand from exercise.models import Exercise # ============================================================================ # DIFFICULTY LEVEL RULES (name-based) # ============================================================================ # Each entry: (compiled_regex, difficulty_level) # Matched against exercise.name.lower(). First match wins. # Patterns use word boundaries (\b) where appropriate to avoid false positives. ADVANCED_NAME_PATTERNS = [ # --- Olympic lifts & derivatives --- r'\bsnatch\b', r'\bclean and jerk\b', r'\bclean & jerk\b', r'\bpower clean\b', r'\bhang clean\b', r'\bsquat clean\b', r'\bclean pull\b', r'\bcluster\b.*\bclean\b', r'\bclean\b.*\bto\b.*\bpress\b', # clean to press / clean to push press r'\bclean\b.*\bto\b.*\bjerk\b', r'\bpush jerk\b', r'\bsplit jerk\b', r'\bjerk\b(?!.*chicken)', # jerk but not "chicken jerk" type food r'\bthruster\b', r'\bwall ball\b', # high coordination + explosive # --- Advanced gymnastics / calisthenics --- r'\bpistol\b.*\bsquat\b', r'\bpistol squat\b', r'\bmuscle.?up\b', r'\bhandstand\b', r'\bhand\s*stand\b', r'\bdragon flag\b', r'\bplanche\b', r'\bl.?sit\b', r'\bhuman flag\b', r'\bfront lever\b', r'\bback lever\b', r'\biron cross\b', r'\bmaltese\b', r'\bstrict press.*handstand\b', r'\bskin the cat\b', r'\bwindshield wiper\b(?!.*stretch)', # weighted windshield wipers, not stretch # --- Advanced barbell lifts --- r'\bturkish get.?up\b', r'\bturkish getup\b', r'\btgu\b', r'\bzercher\b', # zercher squat/carry r'\bdeficit deadlift\b', r'\bsnatch.?grip deadlift\b', r'\bsumo deadlift\b', # wider stance = more mobility demand r'\bhack squat\b.*\bbarbell\b', # barbell hack squat (not machine) r'\boverhead squat\b', r'\bsingle.?leg deadlift\b.*\bbarbell\b', r'\bbarbell\b.*\bsingle.?leg deadlift\b', r'\bscorpion\b', # scorpion press # --- Plyometric / explosive --- r'\bbox jump\b', r'\bdepth jump\b', r'\btuck jump\b', r'\bbroad jump\b', r'\bclap push.?up\b', r'\bclapping push.?up\b', r'\bplyometric push.?up\b', r'\bplyo push.?up\b', r'\bexplosive\b', r'\bkipping\b', # --- Advanced core --- r'\bab.?wheel\b', r'\bab roller\b', r'\btoes.?to.?bar\b', r'\bknees.?to.?elbow\b', r'\bhanging.?leg.?raise\b', r'\bhanging.?knee.?raise\b', ] BEGINNER_NAME_PATTERNS = [ # --- Simple machine isolation --- r'\bleg press\b', r'\bleg extension\b', r'\bleg curl\b', r'\bhamstring curl\b.*\bmachine\b', r'\bmachine\b.*\bhamstring curl\b', r'\bcalf raise\b.*\bmachine\b', r'\bmachine\b.*\bcalf raise\b', r'\bseated calf raise\b', r'\bchest fly\b.*\bmachine\b', r'\bmachine\b.*\bchest fly\b', r'\bpec.?deck\b', r'\bpec fly\b.*\bmachine\b', r'\bcable\b.*\bcurl\b', r'\bcable\b.*\btricep\b', r'\bcable\b.*\bpushdown\b', r'\btricep.?pushdown\b', r'\blat pulldown\b', r'\bseated row\b.*\bmachine\b', r'\bmachine\b.*\brow\b', r'\bsmith machine\b', # --- Basic bodyweight --- r'\bwall sit\b', r'\bwall push.?up\b', r'\bincline push.?up\b', r'\bdead hang\b', r'\bplank\b(?!.*\bjack\b)(?!.*\bup\b.*\bdown\b)', # plank but not plank jacks or up-down planks r'\bside plank\b', r'\bglute bridge\b', r'\bhip bridge\b', r'\bbird.?dog\b', r'\bsuperman\b(?!.*\bpush.?up\b)', r'\bcrunches?\b', r'\bsit.?up\b', r'\bbicycle\b.*\bcrunch\b', r'\bflutter kick\b', r'\bleg raise\b(?!.*\bhanging\b)', # lying leg raise (not hanging) r'\blying\b.*\bleg raise\b', r'\bcalf raise\b(?!.*\bbarbell\b)(?!.*\bsingle\b)', # basic standing calf raise r'\bstanding calf raise\b', # --- Stretches and foam rolling --- r'\bstretch\b', r'\bstretching\b', r'\bfoam roll\b', r'\bfoam roller\b', r'\blacrosse ball\b', r'\bmyofascial\b', r'\bself.?massage\b', # --- Breathing --- r'\bbreathing\b', r'\bbreathe\b', r'\bdiaphragmatic\b', r'\bbox breathing\b', r'\bbreath\b', # --- Basic mobility --- r'\bneck\b.*\bcircle\b', r'\barm\b.*\bcircle\b', r'\bshoulder\b.*\bcircle\b', r'\bankle\b.*\bcircle\b', r'\bhip\b.*\bcircle\b', r'\bwrist\b.*\bcircle\b', r'\bcat.?cow\b', r'\bchild.?s?\s*pose\b', # --- Simple cardio --- r'\bwalking\b(?!.*\blunge\b)', # walking but not walking lunges r'\bwalk\b(?!.*\bout\b)(?!.*\blunge\b)', # walk but not walkouts or walk lunges r'\bjogging\b', r'\bjog\b', r'\bstepping\b', r'\bstep.?up\b(?!.*\bweighted\b)(?!.*\bbarbell\b)(?!.*\bdumbbell\b)', r'\bjumping jack\b', r'\bhigh knee\b', r'\bbutt kick\b', r'\bbutt kicker\b', r'\bmountain climber\b', # --- Simple yoga poses --- r'\bdownward.?dog\b', r'\bupward.?dog\b', r'\bwarrior\b.*\bpose\b', r'\btree\b.*\bpose\b', r'\bcorpse\b.*\bpose\b', r'\bsavasana\b', r'\bchild.?s?\s*pose\b', ] # Compile for performance _ADVANCED_NAME_RE = [(re.compile(p, re.IGNORECASE), 'advanced') for p in ADVANCED_NAME_PATTERNS] _BEGINNER_NAME_RE = [(re.compile(p, re.IGNORECASE), 'beginner') for p in BEGINNER_NAME_PATTERNS] # ============================================================================ # COMPLEXITY RATING RULES (name-based, 1-5 scale) # ============================================================================ # 1 = Single-joint, simple movement (curls, calf raises, stretches) # 2 = Basic compound or standard bodyweight # 3 = Standard compound with moderate coordination (bench press, squat, row) # 4 = Complex multi-joint, unilateral loaded, high coordination demand # 5 = Highly technical (Olympic lifts, advanced gymnastics) COMPLEXITY_5_PATTERNS = [ # --- Olympic lifts --- r'\bsnatch\b', r'\bclean and jerk\b', r'\bclean & jerk\b', r'\bpower clean\b', r'\bhang clean\b', r'\bsquat clean\b', r'\bclean pull\b', r'\bclean\b.*\bto\b.*\bpress\b', r'\bclean\b.*\bto\b.*\bjerk\b', r'\bpush jerk\b', r'\bsplit jerk\b', r'\bjerk\b(?!.*chicken)', # --- Advanced gymnastics --- r'\bmuscle.?up\b', r'\bhandstand\b.*\bpush.?up\b', r'\bplanche\b', r'\bhuman flag\b', r'\bfront lever\b', r'\bback lever\b', r'\biron cross\b', r'\bmaltese\b', r'\bskin the cat\b', # --- Complex loaded movements --- r'\bturkish get.?up\b', r'\bturkish getup\b', r'\btgu\b', r'\boverhead squat\b', ] COMPLEXITY_4_PATTERNS = [ # --- Complex compound --- r'\bthruster\b', r'\bwall ball\b', r'\bzercher\b', r'\bdeficit deadlift\b', r'\bsumo deadlift\b', r'\bsnatch.?grip deadlift\b', r'\bpistol\b.*\bsquat\b', r'\bpistol squat\b', r'\bdragon flag\b', r'\bl.?sit\b', r'\bhandstand\b(?!.*\bpush.?up\b)', # handstand hold (not HSPU, that's 5) r'\bwindshield wiper\b', r'\btoes.?to.?bar\b', r'\bknees.?to.?elbow\b', r'\bkipping\b', # --- Single-leg loaded (barbell/dumbbell) --- r'\bsingle.?leg deadlift\b', r'\bsingle.?leg rdl\b', r'\bsingle.?leg squat\b(?!.*\bpistol\b)', r'\bbulgarian split squat\b', r'\brear.?foot.?elevated\b.*\bsplit\b', # --- Explosive / plyometric --- r'\bbox jump\b', r'\bdepth jump\b', r'\btuck jump\b', r'\bbroad jump\b', r'\bclap push.?up\b', r'\bclapping push.?up\b', r'\bplyometric push.?up\b', r'\bplyo push.?up\b', r'\bexplosive\b', # --- Advanced core --- r'\bab.?wheel\b', r'\bab roller\b', r'\bhanging.?leg.?raise\b', r'\bhanging.?knee.?raise\b', # --- Complex upper body --- r'\barcher\b.*\bpush.?up\b', r'\bdiamond push.?up\b', r'\bpike push.?up\b', r'\bmilitary press\b', r'\bstrict press\b', # --- Carries (unilateral loaded / coordination) --- r'\bfarmer.?s?\s*carry\b', r'\bfarmer.?s?\s*walk\b', r'\bsuitcase carry\b', r'\boverhead carry\b', r'\brack carry\b', r'\bwaiter.?s?\s*carry\b', r'\bwaiter.?s?\s*walk\b', r'\bcross.?body carry\b', ] COMPLEXITY_1_PATTERNS = [ # --- Single-joint isolation --- r'\bbicep curl\b', r'\bcurl\b(?!.*\bleg\b)(?!.*\bhamstring\b)(?!.*\bnordic\b)', r'\btricep extension\b', r'\btricep kickback\b', r'\btricep.?pushdown\b', r'\bskull.?crusher\b', r'\bcable\b.*\bfly\b', r'\bcable\b.*\bpushdown\b', r'\bcable\b.*\bcurl\b', r'\bleg extension\b', r'\bleg curl\b', r'\bhamstring curl\b', r'\bcalf raise\b', r'\blateral raise\b', r'\bfront raise\b', r'\brear delt fly\b', r'\breverse fly\b', r'\bpec.?deck\b', r'\bchest fly\b.*\bmachine\b', r'\bmachine\b.*\bchest fly\b', r'\bshrug\b', r'\bwrist curl\b', r'\bforearm curl\b', r'\bconcentration curl\b', r'\bhammer curl\b', r'\bpreacher curl\b', r'\bincline curl\b', # --- Stretches / foam rolling --- r'\bstretch\b', r'\bstretching\b', r'\bfoam roll\b', r'\bfoam roller\b', r'\blacrosse ball\b', r'\bmyofascial\b', r'\bself.?massage\b', # --- Breathing --- r'\bbreathing\b', r'\bbreathe\b', r'\bdiaphragmatic\b', r'\bbox breathing\b', r'\bbreath\b', # --- Simple isolation machines --- r'\bpec fly\b', r'\bseated calf raise\b', # --- Simple mobility --- r'\bneck\b.*\bcircle\b', r'\barm\b.*\bcircle\b', r'\bshoulder\b.*\bcircle\b', r'\bankle\b.*\bcircle\b', r'\bhip\b.*\bcircle\b', r'\bwrist\b.*\bcircle\b', r'\bcat.?cow\b', r'\bchild.?s?\s*pose\b', r'\bcorpse\b.*\bpose\b', r'\bsavasana\b', ] COMPLEXITY_2_PATTERNS = [ # --- Basic bodyweight compound --- r'\bpush.?up\b(?!.*\bclap\b)(?!.*\bplyometric\b)(?!.*\bplyo\b)(?!.*\bpike\b)(?!.*\bdiamond\b)(?!.*\barcher\b)(?!.*\bexplosive\b)', r'\bsit.?up\b', r'\bcrunches?\b', r'\bbicycle\b.*\bcrunch\b', r'\bflutter kick\b', r'\bplank\b', r'\bside plank\b', r'\bglute bridge\b', r'\bhip bridge\b', r'\bbird.?dog\b', r'\bsuperman\b', r'\bwall sit\b', r'\bdead hang\b', r'\bbodyweight squat\b', r'\bair squat\b', r'\blying\b.*\bleg raise\b', r'\bleg raise\b(?!.*\bhanging\b)', r'\bjumping jack\b', r'\bhigh knee\b', r'\bbutt kick\b', r'\bbutt kicker\b', r'\bmountain climber\b', r'\bstep.?up\b(?!.*\bweighted\b)(?!.*\bbarbell\b)', # --- Basic machine compound --- r'\bleg press\b', r'\blat pulldown\b', r'\bseated row\b.*\bmachine\b', r'\bmachine\b.*\brow\b', r'\bchest press\b.*\bmachine\b', r'\bmachine\b.*\bchest press\b', r'\bsmith machine\b', # --- Cardio / locomotion --- r'\bwalking\b', r'\bwalk\b(?!.*\bout\b)', r'\bjogging\b', r'\bjog\b', r'\brunning\b', r'\bsprinting\b', r'\browing\b.*\bmachine\b', r'\bassault bike\b', r'\bstationary bike\b', r'\belliptical\b', r'\bjump rope\b', r'\bskipping\b', # --- Simple yoga poses --- r'\bdownward.?dog\b', r'\bupward.?dog\b', r'\bwarrior\b.*\bpose\b', r'\btree\b.*\bpose\b', # --- Basic combat --- r'\bjab\b', r'\bcross\b(?!.*\bbody\b.*\bcarry\b)', r'\bshadow\s*box\b', # --- Basic resistance band --- r'\bband\b.*\bpull.?apart\b', r'\bband\b.*\bface pull\b', ] # Compile for performance _COMPLEXITY_5_RE = [(re.compile(p, re.IGNORECASE), 5) for p in COMPLEXITY_5_PATTERNS] _COMPLEXITY_4_RE = [(re.compile(p, re.IGNORECASE), 4) for p in COMPLEXITY_4_PATTERNS] _COMPLEXITY_1_RE = [(re.compile(p, re.IGNORECASE), 1) for p in COMPLEXITY_1_PATTERNS] _COMPLEXITY_2_RE = [(re.compile(p, re.IGNORECASE), 2) for p in COMPLEXITY_2_PATTERNS] # ============================================================================ # MOVEMENT PATTERN -> DIFFICULTY FALLBACK # ============================================================================ # When name-based rules don't match, use movement_patterns field. # Keys are substring matches against movement_patterns (lowercased). # Order matters: first match wins. MOVEMENT_PATTERN_DIFFICULTY = [ # --- Advanced patterns --- ('plyometric', 'advanced'), ('olympic', 'advanced'), # --- Beginner patterns --- ('massage', 'beginner'), ('breathing', 'beginner'), ('mobility - static', 'beginner'), ('yoga', 'beginner'), ('stretch', 'beginner'), # --- Intermediate (default for all loaded / compound patterns) --- ('upper push - vertical', 'intermediate'), ('upper push - horizontal', 'intermediate'), ('upper pull - vertical', 'intermediate'), ('upper pull - horizonal', 'intermediate'), # note: typo matches DB ('upper pull - horizontal', 'intermediate'), ('upper push', 'intermediate'), ('upper pull', 'intermediate'), ('lower push - squat', 'intermediate'), ('lower push - lunge', 'intermediate'), ('lower pull - hip hinge', 'intermediate'), ('lower push', 'intermediate'), ('lower pull', 'intermediate'), ('core - anti-extension', 'intermediate'), ('core - rotational', 'intermediate'), ('core - anti-rotation', 'intermediate'), ('core - carry', 'intermediate'), ('core', 'intermediate'), ('arms', 'intermediate'), ('machine', 'intermediate'), ('balance', 'intermediate'), ('mobility - dynamic', 'intermediate'), ('mobility', 'intermediate'), ('combat', 'intermediate'), ('cardio/locomotion', 'intermediate'), ('cardio', 'intermediate'), ] # ============================================================================ # MOVEMENT PATTERN -> COMPLEXITY FALLBACK # ============================================================================ # When name-based rules don't match, use movement_patterns field. # Order matters: first match wins. MOVEMENT_PATTERN_COMPLEXITY = [ # --- Complexity 5 --- ('olympic', 5), # --- Complexity 4 --- ('plyometric', 4), ('core - carry', 4), # --- Complexity 3 (standard compound) --- ('upper push - vertical', 3), ('upper push - horizontal', 3), ('upper pull - vertical', 3), ('upper pull - horizonal', 3), # typo matches DB ('upper pull - horizontal', 3), ('upper push', 3), ('upper pull', 3), ('lower push - squat', 3), ('lower push - lunge', 3), ('lower pull - hip hinge', 3), ('lower push', 3), ('lower pull', 3), ('core - anti-extension', 3), ('core - rotational', 3), ('core - anti-rotation', 3), ('balance', 3), ('combat', 3), # --- Complexity 2 --- ('core', 2), ('machine', 2), ('arms', 2), ('mobility - dynamic', 2), ('cardio/locomotion', 2), ('cardio', 2), ('yoga', 2), # --- Complexity 1 --- ('mobility - static', 1), ('massage', 1), ('stretch', 1), ('breathing', 1), ('mobility', 1), # generic mobility fallback ] # ============================================================================ # EQUIPMENT-BASED ADJUSTMENTS # ============================================================================ # Some exercises can be bumped up or down based on equipment context. # These are applied AFTER name + movement_pattern rules as modifiers. def _apply_equipment_adjustments(exercise, difficulty, complexity): """ Apply equipment-based adjustments to difficulty and complexity. - Barbell compound lifts: ensure at least intermediate / 3 - Kettlebell: bump complexity +1 for most movements (unstable load) - Stability ball: bump complexity +1 (balance demand) - Suspension trainer (TRX): bump complexity +1 (instability) - Machine: cap complexity at 2 (guided path, low coordination) - Resistance band: no change """ name_lower = (exercise.name or '').lower() equip = (exercise.equipment_required or '').lower() patterns = (exercise.movement_patterns or '').lower() # --- Machine cap: complexity should not exceed 2 --- is_machine = ( 'machine' in equip or 'machine' in name_lower or 'smith' in name_lower or 'machine' in patterns ) # But only if it's truly a guided-path machine, not cable is_cable = 'cable' in equip or 'cable' in name_lower if is_machine and not is_cable: complexity = min(complexity, 2) # --- Kettlebell bump: +1 complexity (unstable center of mass) --- is_kettlebell = 'kettlebell' in equip or 'kettlebell' in name_lower if is_kettlebell and complexity < 5: # Only bump for compound movements, not simple swings etc. if any(kw in patterns for kw in ['upper push', 'upper pull', 'lower push', 'lower pull', 'core - carry']): complexity = min(complexity + 1, 5) # --- Stability ball bump: +1 complexity --- is_stability_ball = 'stability ball' in equip or 'stability ball' in name_lower if is_stability_ball and complexity < 5: complexity = min(complexity + 1, 5) # --- Suspension trainer (TRX) bump: +1 complexity --- is_suspension = ( 'suspension' in equip or 'trx' in name_lower or 'suspension' in name_lower ) if is_suspension and complexity < 5: complexity = min(complexity + 1, 5) # --- Barbell floor: ensure at least intermediate / 3 for big lifts --- is_barbell = 'barbell' in equip or 'barbell' in name_lower if is_barbell: for lift in ['squat', 'deadlift', 'bench', 'press', 'row', 'lunge']: if lift in name_lower: if difficulty == 'beginner': difficulty = 'intermediate' complexity = max(complexity, 3) break return difficulty, complexity # ============================================================================ # CLASSIFICATION FUNCTIONS # ============================================================================ def classify_difficulty(exercise): """Return difficulty_level for an exercise. First match wins.""" name = (exercise.name or '').lower() # 1. Check advanced name patterns for regex, level in _ADVANCED_NAME_RE: if regex.search(name): return level # 2. Check beginner name patterns for regex, level in _BEGINNER_NAME_RE: if regex.search(name): return level # 3. Movement pattern fallback patterns = (exercise.movement_patterns or '').lower() if patterns: for keyword, level in MOVEMENT_PATTERN_DIFFICULTY: if keyword in patterns: return level # 4. Default: intermediate return 'intermediate' def classify_complexity(exercise): """Return complexity_rating (1-5) for an exercise. First match wins.""" name = (exercise.name or '').lower() # 1. Complexity 5 for regex, rating in _COMPLEXITY_5_RE: if regex.search(name): return rating # 2. Complexity 4 for regex, rating in _COMPLEXITY_4_RE: if regex.search(name): return rating # 3. Complexity 1 (check before 2 since some patterns overlap) for regex, rating in _COMPLEXITY_1_RE: if regex.search(name): return rating # 4. Complexity 2 for regex, rating in _COMPLEXITY_2_RE: if regex.search(name): return rating # 5. Movement pattern fallback patterns = (exercise.movement_patterns or '').lower() if patterns: for keyword, rating in MOVEMENT_PATTERN_COMPLEXITY: if keyword in patterns: return rating # 6. Default: 3 (moderate) return 3 def classify_exercise(exercise): """ Classify a single exercise and return (difficulty_level, complexity_rating). """ difficulty = classify_difficulty(exercise) complexity = classify_complexity(exercise) # Apply equipment-based adjustments difficulty, complexity = _apply_equipment_adjustments( exercise, difficulty, complexity ) return difficulty, complexity # ============================================================================ # MANAGEMENT COMMAND # ============================================================================ class Command(BaseCommand): help = ( 'Classify all exercises by difficulty_level and complexity_rating ' 'using name-based keyword rules and movement_patterns fallback.' ) def add_arguments(self, parser): parser.add_argument( '--dry-run', action='store_true', help='Show what would change without saving.', ) parser.add_argument( '--verbose', action='store_true', help='Print each exercise classification.', ) parser.add_argument( '--only-unset', action='store_true', help='Only classify exercises that have NULL difficulty/complexity.', ) def handle(self, *args, **options): import warnings warnings.warn( "classify_exercises is deprecated. Use 'populate_exercise_fields' instead, " "which populates all 8 exercise fields including difficulty and complexity.", DeprecationWarning, stacklevel=2, ) self.stderr.write(self.style.WARNING( "DEPRECATED: Use 'python manage.py populate_exercise_fields' instead. " "This command only sets difficulty_level and complexity_rating, while " "populate_exercise_fields sets all 8 fields." )) dry_run = options['dry_run'] verbose = options['verbose'] only_unset = options['only_unset'] exercises = Exercise.objects.all().order_by('name') if only_unset: exercises = exercises.filter( difficulty_level__isnull=True ) | exercises.filter( complexity_rating__isnull=True ) exercises = exercises.distinct().order_by('name') total = exercises.count() updated = 0 unchanged = 0 # Counters for summary difficulty_counts = {'beginner': 0, 'intermediate': 0, 'advanced': 0} complexity_counts = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0} for ex in exercises: difficulty, complexity = classify_exercise(ex) difficulty_counts[difficulty] += 1 complexity_counts[complexity] += 1 changed = ( ex.difficulty_level != difficulty or ex.complexity_rating != complexity ) if verbose: marker = '*' if changed else ' ' self.stdout.write( f' {marker} {ex.name:<55} ' f'difficulty={difficulty:<14} ' f'complexity={complexity} ' f'patterns="{ex.movement_patterns or ""}"' ) if changed: updated += 1 if not dry_run: ex.difficulty_level = difficulty ex.complexity_rating = complexity ex.save(update_fields=['difficulty_level', 'complexity_rating']) else: unchanged += 1 # Summary prefix = '[DRY RUN] ' if dry_run else '' self.stdout.write('') self.stdout.write(f'{prefix}Processed {total} exercises:') self.stdout.write(f' {updated} updated, {unchanged} unchanged') self.stdout.write('') self.stdout.write('Difficulty distribution:') for level, count in difficulty_counts.items(): pct = (count / total * 100) if total else 0 self.stdout.write(f' {level:<14} {count:>5} ({pct:.1f}%)') self.stdout.write('') self.stdout.write('Complexity distribution:') for rating in sorted(complexity_counts.keys()): count = complexity_counts[rating] pct = (count / total * 100) if total else 0 self.stdout.write(f' {rating} {count:>5} ({pct:.1f}%)')