Files
WerkoutAPI/generator/management/commands/classify_exercises.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

799 lines
26 KiB
Python

"""
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}%)')