Files
WerkoutAPI/generator/management/commands/populate_exercise_fields.py
Trey t c80c66c2e5 Codebase hardening: 102 fixes across 35+ files
Deep audit identified 106 findings; 102 fixed, 4 deferred. Covers 8 areas:

- Settings & deploy: env-gated DEBUG/SECRET_KEY, HTTPS headers, gunicorn, celery worker
- Auth (registered_user): password write_only, request.data fixes, transaction safety, proper HTTP status codes
- Workout app: IDOR protection, get_object_or_404, prefetch_related N+1 fixes, transaction.atomic
- Video/scripts: path traversal sanitization, HLS trigger guard, auth on cache wipe
- Models (exercise/equipment/muscle/superset): null-safe __str__, stable IDs, prefetch support
- Generator views: helper for registered_user lookup, logger.exception, bulk_update, transaction wrapping
- Generator core (rules/selector/generator): push-pull ratio, type affinity normalization, modality checks, side-pair exact match, word-boundary regex, equipment cache clearing
- Generator services (plan_builder/analyzer/normalizer): transaction.atomic, muscle cache, bulk_update, glutes classification fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:29:14 -06:00

1055 lines
38 KiB
Python

"""
Populate the 8 new Exercise fields using rule-based derivation from existing data.
Fields populated:
1. is_compound — derived from joints_used count (3+ joints = compound)
2. difficulty_level — derived from movement patterns, equipment, and name keywords
3. exercise_tier — derived from is_compound + movement patterns + is_weight
4. complexity_rating — derived from movement pattern count, equipment, and name keywords
5. hr_elevation_rating — derived from movement patterns and muscle mass involved
6. impact_level — derived from movement patterns and name keywords
7. stretch_position — derived from biomechanical analysis of exercise type
8. progression_of — set only for clear, verifiable progressions
All rules are based on exercise science principles; no values are guessed.
"""
import re
from django.core.management.base import BaseCommand
from exercise.models import Exercise
# ── 1. is_compound ────────────────────────────────────────────────────────
# Compound = exercise where multiple joints are PRIMARY movers under load.
# The joints_used field includes stabilizer joints (shoulder in curls, wrist in rows),
# so we use movement patterns as the primary signal, with joint count as tiebreaker.
# These movement patterns are ALWAYS isolation (single primary joint)
ISOLATION_PATTERNS = {'arms'}
# These patterns are never compound in the programming sense
NON_COMPOUND_PATTERNS = {
'yoga', 'massage', 'breathing', 'mobility - static', 'mobility,mobility - static',
}
# Name keywords that indicate isolation regardless of joints listed
ISOLATION_NAME_KEYWORDS = [
'curl', 'raise', 'shrug', 'kickback', 'kick back', 'pushdown',
'push down', 'fly ', 'flye', 'wrist', 'forearm', 'calf raise',
'heel raise', 'leg extension', 'leg curl', 'hamstring curl',
'reverse fly', 'pec deck', 'concentration',
]
# Name keywords that indicate compound regardless
COMPOUND_NAME_KEYWORDS = [
'squat', 'deadlift', 'bench press', 'overhead press', 'row',
'pull-up', 'pull up', 'chin-up', 'chin up', 'dip', 'lunge',
'clean', 'snatch', 'thruster', 'push press', 'push-up', 'push up',
'good morning', 'hip thrust', 'step up', 'step-up', 'burpee',
'man maker', 'turkish',
]
def derive_is_compound(ex):
mp = (ex.movement_patterns or '').lower()
name_lower = (ex.name or '').lower()
# Non-training exercises: never compound
if mp in NON_COMPOUND_PATTERNS or mp.startswith('yoga') or mp.startswith('massage') or mp == 'breathing':
return False
# Mobility exercises: never compound
if mp.startswith('mobility') and 'plyometric' not in mp:
return False
# Arms pattern = isolation by definition
if mp == 'arms':
return False
# Check isolation name keywords (more specific, check first)
for kw in ISOLATION_NAME_KEYWORDS:
if kw in name_lower:
# Exception: "clean and press" contains "press" but is compound
# We check isolation first, so if both match, isolation wins
# But some should still be compound (e.g., "barbell curl to press")
if any(ckw in name_lower for ckw in ['to press', 'and press', 'clean', 'squat']):
return True
return False
# Check compound name keywords
for kw in COMPOUND_NAME_KEYWORDS:
if kw in name_lower:
return True
# Core-only exercises: not compound (load targets core, other joints position body)
if mp.startswith('core') and 'carry' not in mp and 'upper' not in mp and 'lower' not in mp:
return False
# Machine exercises: depends on movement pattern
if 'machine' in mp:
# Machine compound patterns (leg press, lat pulldown, chest press)
if any(p in mp for p in ['squat', 'push', 'pull', 'hip hinge']):
return True
return False
# For remaining exercises, use joint count (3+ primary joints)
joints = [j.strip() for j in (ex.joints_used or '').split(',') if j.strip()]
# Discount wrist (grip stabilizer) when counting
primary_joints = [j for j in joints if j != 'wrist']
return len(primary_joints) >= 3
# ── 2. difficulty_level ───────────────────────────────────────────────────
ADVANCED_KEYWORDS = [
'pistol', 'muscle up', 'muscle-up', 'handstand', 'dragon flag', 'planche',
'l-sit', 'turkish get up', 'turkish get-up', 'snatch', 'clean and jerk',
'clean & jerk', 'windmill', 'scorpion', 'single-leg barbell', 'sissy squat',
'nordic', 'dragon', 'human flag', 'iron cross', 'front lever', 'back lever',
'skin the cat', 'one arm', 'one-arm', 'archer', 'typewriter',
'barbell overhead squat',
]
ADVANCED_EXACT = {
'crow', 'crane', 'firefly', 'scorpion', 'wheel',
'king pigeon', 'flying pigeon', 'eight angle pose',
}
BEGINNER_PATTERNS = {
'massage', 'breathing', 'mobility,mobility - static',
'mobility - static', 'yoga,mobility,mobility - static',
'yoga,mobility - static', 'yoga,mobility - static,mobility',
}
BEGINNER_KEYWORDS = [
'foam roll', 'lacrosse ball', 'stretch', 'child\'s pose', 'corpse',
'breathing', 'cat cow', 'cat-cow', 'band assisted', 'assisted dip',
'assisted chin', 'assisted pull', 'wall sit',
]
INTERMEDIATE_FLOOR_KEYWORDS = [
'barbell', 'kettlebell', 'trap bar',
]
def derive_difficulty_level(ex):
name_lower = (ex.name or '').lower()
mp = (ex.movement_patterns or '').lower()
equip = (ex.equipment_required or '').lower()
# Check advanced keywords
for kw in ADVANCED_KEYWORDS:
if kw in name_lower:
return 'advanced'
# Word-boundary match for short keywords that could be substrings
if re.search(r'\bl[ -]?sit\b', name_lower):
return 'advanced'
for kw in ADVANCED_EXACT:
if name_lower == kw:
return 'advanced'
# Advanced: plyometric + true weighted resistance
weight_equip = {'barbell', 'dumbbell', 'kettlebell', 'weighted vest', 'plate', 'medicine ball', 'ez bar', 'trap bar'}
equip_lower = (ex.equipment_required or '').lower()
if 'plyometric' in mp and any(we in equip_lower for we in weight_equip):
return 'advanced'
# Beginner categories
if mp in BEGINNER_PATTERNS:
return 'beginner'
for kw in BEGINNER_KEYWORDS:
if kw in name_lower:
return 'beginner'
# Massage is always beginner
if 'massage' in mp or 'foam roll' in name_lower:
return 'beginner'
# Simple bodyweight exercises
if not ex.is_weight and not equip and mp in (
'core', 'core,core - anti-extension', 'core,core - rotational',
'core,core - anti-rotation', 'core,core - anti-lateral flexion',
):
return 'beginner'
# Bodyweight upper push/pull basics
if not ex.is_weight and not equip:
basic_bw = ['push-up', 'push up', 'body weight squat', 'bodyweight squat',
'air squat', 'glute bridge', 'bird dog', 'dead bug',
'clamshell', 'wall sit', 'plank', 'mountain climber',
'jumping jack', 'high knee', 'butt kick']
for kw in basic_bw:
if kw in name_lower:
return 'beginner'
# Barbell/kettlebell movements default to intermediate minimum
for kw in INTERMEDIATE_FLOOR_KEYWORDS:
if kw in equip:
return 'intermediate'
# Machine exercises are generally beginner-intermediate
if 'machine' in mp:
return 'beginner'
# Yoga: most poses are intermediate unless specifically beginner/advanced
if mp.startswith('yoga'):
beginner_yoga = ['child', 'corpse', 'mountain', 'warrior i', 'warrior 1',
'downward dog', 'cat cow', 'cobra', 'bridge', 'tree']
for kw in beginner_yoga:
if kw in name_lower:
return 'beginner'
advanced_yoga = ['crow', 'crane', 'firefly', 'scorpion', 'wheel',
'headstand', 'handstand', 'king pigeon', 'eight angle',
'flying pigeon', 'peacock', 'side crow']
for kw in advanced_yoga:
if kw in name_lower:
return 'advanced'
return 'intermediate'
# Combat
if 'combat' in mp:
if 'spinning' in name_lower or 'flying' in name_lower:
return 'advanced'
return 'intermediate'
# Cardio
if mp == 'cardio/locomotion':
return 'beginner'
# Dynamic mobility
if 'mobility - dynamic' in mp or 'mobility,mobility - dynamic' in mp:
return 'beginner'
# Balance exercises with weight are intermediate+
if 'balance' in mp:
if ex.is_weight:
return 'intermediate'
return 'beginner'
# Default
return 'intermediate'
# ── 3. exercise_tier ──────────────────────────────────────────────────────
PRIMARY_MOVEMENT_PATTERNS = {
'lower push,lower push - squat',
'lower pull,lower pull - hip hinge',
'upper push,upper push - horizontal',
'upper push,upper push - vertical',
'upper pull,upper pull - horizonal',
'upper pull,upper pull - vertical',
}
# Secondary patterns: lunges, core carries, compound but not primary lift patterns
SECONDARY_MOVEMENT_PATTERNS = {
'lower push,lower push - lunge',
'lower pull', # hip thrusts, glute bridges
'core,core - carry',
'core - carry,core',
'core,core - anti-lateral flexion,core - carry',
'core,core - carry,core - anti-lateral flexion',
}
# Non-training patterns: these are always accessory
ACCESSORY_ONLY_PATTERNS = {
'yoga', 'massage', 'breathing', 'mobility,mobility - static',
'mobility - static', 'mobility,mobility - dynamic',
'mobility - dynamic', 'cardio/locomotion', 'balance',
'yoga,mobility,mobility - static', 'yoga,mobility - static',
'yoga,mobility - static,mobility', 'yoga,balance',
'yoga,breathing', 'yoga,mobility', 'yoga,massage,massage',
'yoga,mobility,core - rotational', 'yoga,mobility,mobility - dynamic',
}
def derive_exercise_tier(ex, is_compound):
mp = (ex.movement_patterns or '').lower()
# Non-training exercises are always accessory
if mp in ACCESSORY_ONLY_PATTERNS or mp.startswith('yoga') or mp.startswith('massage'):
return 'accessory'
# Weighted compound exercises in primary movement patterns
if is_compound and mp in PRIMARY_MOVEMENT_PATTERNS and ex.is_weight:
return 'primary'
# Compound exercises in primary patterns without weight (e.g., pull-ups)
if is_compound and mp in PRIMARY_MOVEMENT_PATTERNS:
name_lower = (ex.name or '').lower()
# Pull-ups and chin-ups are primary even without is_weight
if any(kw in name_lower for kw in ['pull-up', 'pull up', 'chin-up', 'chin up']):
return 'primary'
# Bodyweight squats, push-ups are secondary
return 'secondary'
# Compound exercises in secondary patterns
if is_compound and (mp in SECONDARY_MOVEMENT_PATTERNS or 'lunge' in mp):
return 'secondary'
# Weighted compound exercises not in named patterns
if is_compound and ex.is_weight:
return 'secondary'
# Non-weighted compound
if is_compound:
return 'secondary'
# Isolation exercises
if mp == 'arms':
return 'accessory'
# Core exercises (non-compound)
if mp.startswith('core'):
return 'accessory'
# Plyometrics
if 'plyometric' in mp:
return 'secondary'
# Combat
if 'combat' in mp:
return 'secondary'
# Machine isolation
if 'machine' in mp:
# Machine compound movements
if is_compound:
return 'secondary'
return 'accessory'
return 'accessory'
# ── 4. complexity_rating (1-5) ────────────────────────────────────────────
COMPLEXITY_5_KEYWORDS = [
'snatch', 'clean and jerk', 'clean & jerk', 'turkish get up',
'turkish get-up', 'muscle up', 'muscle-up', 'pistol squat',
'handstand push', 'handstand walk', 'human flag', 'planche',
'front lever', 'back lever', 'iron cross',
]
COMPLEXITY_4_KEYWORDS = [
'overhead squat', 'windmill', 'get up', 'get-up',
'renegade', 'man maker', 'man-maker', 'thruster',
'archer', 'typewriter', 'one arm', 'one-arm',
]
# Single-leg/arm is only complex for FREE WEIGHT exercises, not machines
COMPLEXITY_4_UNILATERAL = [
'single-leg', 'single leg', 'one leg', 'one-leg',
]
# Olympic lift variants (complexity 4, not quite 5)
COMPLEXITY_4_OLYMPIC = [
'hang clean', 'power clean', 'clean pull', 'clean high pull',
'hang snatch', 'power snatch',
]
COMPLEXITY_1_KEYWORDS = [
'foam roll', 'lacrosse ball', 'stretch', 'breathing',
'massage', 'child\'s pose', 'corpse', 'mountain pose',
]
COMPLEXITY_1_PATTERNS = {
'massage', 'breathing', 'mobility,mobility - static',
'mobility - static', 'yoga,mobility,mobility - static',
}
def derive_complexity_rating(ex, is_compound):
name_lower = (ex.name or '').lower()
mp = (ex.movement_patterns or '').lower()
# Level 5: Olympic lifts, gymnastics movements
for kw in COMPLEXITY_5_KEYWORDS:
if kw in name_lower:
return 5
# Level 4: Olympic lift variants
for kw in COMPLEXITY_4_OLYMPIC:
if kw in name_lower:
return 4
# Level 4: Clean as distinct word (not "cleaning")
if re.search(r'\bclean\b', name_lower):
return 4
# Level 4: Complex compound movements
for kw in COMPLEXITY_4_KEYWORDS:
if kw in name_lower:
return 4
# Level 4: Single-leg/arm for FREE WEIGHT only (not machines)
if 'machine' not in mp:
for kw in COMPLEXITY_4_UNILATERAL:
if kw in name_lower:
# Suspension trainers add complexity
if 'suspension' in (ex.equipment_required or '').lower():
return 4
# Weighted single-leg = 4, bodyweight single-leg = 3
if ex.is_weight:
return 4
return 3
# Level 1: Simple recovery/mobility
for kw in COMPLEXITY_1_KEYWORDS:
if kw in name_lower:
return 1
if mp in COMPLEXITY_1_PATTERNS:
return 1
# Massage
if 'massage' in mp:
return 1
# Level 1-2: Static holds / simple yoga
if mp.startswith('yoga'):
# Basic poses
basic = ['warrior', 'tree', 'bridge', 'cobra', 'downward', 'chair',
'triangle', 'pigeon', 'cat', 'cow']
for kw in basic:
if kw in name_lower:
return 2
return 3 # Most yoga has moderate complexity
# Dynamic mobility
if 'mobility - dynamic' in mp:
return 2
# Machine exercises: generally lower complexity (guided path)
if 'machine' in mp:
# Machine single-leg is slightly more complex
if 'single' in name_lower:
return 2
return 2
# Arms isolation — curls, extensions, raises, kickbacks
if mp == 'arms':
return 1
isolation_kw = ['curl', 'kickback', 'kick back', 'pushdown', 'push down',
'lateral raise', 'front raise', 'shrug', 'wrist']
for kw in isolation_kw:
if kw in name_lower and is_compound is False:
return 1
# Basic cardio
if mp == 'cardio/locomotion':
return 2
# Combat
if 'combat' in mp:
if 'combination' in name_lower or 'combo' in name_lower:
return 4
return 3
# Plyometrics
if 'plyometric' in mp:
if 'box jump' in name_lower or 'depth' in name_lower:
return 4
return 3
# Core
if mp.startswith('core'):
if 'carry' in mp:
return 3
if is_compound:
return 3
return 2
# Balance
if 'balance' in mp:
if ex.is_weight:
return 4
return 3
# Compound weighted exercises
if is_compound and ex.is_weight:
# Count movement patterns as proxy for complexity
patterns = [p.strip() for p in mp.split(',') if p.strip()]
if len(patterns) >= 4:
return 4
return 3
# Compound bodyweight
if is_compound:
return 3
# Default
return 2
# ── 5. hr_elevation_rating (1-10) ────────────────────────────────────────
# High-HR keywords (regardless of movement pattern)
HIGH_HR_KEYWORDS = [
'burpee', 'man maker', 'man-maker', 'thruster', 'battle rope',
'slam', 'sprint',
]
MODERATE_HR_KEYWORDS = [
'mountain climber', 'bear crawl', 'inchworm', 'walkout',
]
def derive_hr_elevation(ex, is_compound):
mp = (ex.movement_patterns or '').lower()
name_lower = (ex.name or '').lower()
# Breathing / massage: minimal
if mp == 'breathing' or 'massage' in mp or 'foam roll' in name_lower:
return 1
# Static mobility / static stretches
if 'mobility - static' in mp and 'dynamic' not in mp:
return 1
# High HR keywords (check before movement pattern defaults)
for kw in HIGH_HR_KEYWORDS:
if kw in name_lower:
return 9
# Moderate-high HR keywords
for kw in MODERATE_HR_KEYWORDS:
if kw in name_lower:
return 7
# Yoga: low unless vigorous flow
if mp.startswith('yoga'):
vigorous = ['chaturanga', 'sun salutation', 'vinyasa', 'chair', 'warrior iii',
'crow', 'crane', 'handstand', 'headstand']
for kw in vigorous:
if kw in name_lower:
return 4
return 2
# Plyometric + cardio = very high
if 'plyometric' in mp and 'cardio' in mp:
return 9
# Plyometric = high
if 'plyometric' in mp:
if 'burpee' in name_lower or 'man maker' in name_lower:
return 9
return 8
# Cardio/locomotion
if mp == 'cardio/locomotion':
high_cardio = ['bike', 'rower', 'elliptical', 'treadmill', 'stair',
'sprint', 'run', 'jog']
for kw in high_cardio:
if kw in name_lower:
return 8
return 7
# Combat: moderate-high
if 'combat' in mp:
if 'burnout' in name_lower:
return 8
return 6
# Dynamic mobility
if 'mobility - dynamic' in mp:
return 3
# Balance (low impact)
if mp == 'balance' or (mp.startswith('balance') and not is_compound):
return 2
# Lower body compound (large muscle mass = higher HR)
lower_compound = ['lower push,lower push - squat', 'lower pull,lower pull - hip hinge']
if mp in lower_compound and is_compound:
if ex.is_weight:
return 7
return 6
# Lunges
if 'lunge' in mp:
if ex.is_weight:
return 7
return 6
# Lower pull (hip thrusts, glute bridges)
if mp == 'lower pull':
if ex.is_weight:
return 5
return 4
# Upper body compound
upper_compound = [
'upper push,upper push - horizontal', 'upper push,upper push - vertical',
'upper pull,upper pull - horizonal', 'upper pull,upper pull - vertical',
]
if mp in upper_compound and is_compound:
if ex.is_weight:
return 5
return 4
# Core
if mp.startswith('core'):
if 'carry' in mp:
return 5 # Loaded carries elevate HR
return 3
# Arms (isolation, small muscle mass)
if mp == 'arms':
return 3
# Machine exercises
if 'machine' in mp:
if is_compound:
return 5
return 3
# Multi-pattern compound exercises (full body)
patterns = [p.strip() for p in mp.split(',') if p.strip()]
if len(patterns) >= 3 and is_compound:
# Check if it spans upper + lower body = true full body
has_upper = any('upper' in p or 'pull' in p for p in patterns)
has_lower = any('lower' in p or 'hip' in p or 'squat' in p or 'lunge' in p for p in patterns)
if has_upper and has_lower:
return 8 # Full body complex
return 7
# Default compound
if is_compound:
return 5
return 4
# ── 6. impact_level (none/low/medium/high) ───────────────────────────────
HIGH_IMPACT_KEYWORDS = [
'jump', 'hop', 'bound', 'skip', 'box jump', 'depth jump',
'tuck jump', 'squat jump', 'split jump', 'broad jump',
'burpee', 'slam', 'sprint',
]
def derive_impact_level(ex):
name_lower = (ex.name or '').lower()
mp = (ex.movement_patterns or '').lower()
# High impact: plyometrics, jumping exercises
if 'plyometric' in mp:
return 'high'
for kw in HIGH_IMPACT_KEYWORDS:
if kw in name_lower:
return 'high'
# Medium impact: fast ground-contact exercises (mountain climbers, bear crawls)
medium_impact_kw = ['mountain climber', 'bear crawl', 'battle rope']
for kw in medium_impact_kw:
if kw in name_lower:
return 'medium'
# No impact: seated, lying, machine, yoga, massage, breathing, static mobility
no_impact_patterns = ['massage', 'breathing', 'mobility - static']
for nip in no_impact_patterns:
if nip in mp:
return 'none'
if mp.startswith('yoga'):
# Standing yoga poses have low impact
standing = ['warrior', 'tree', 'chair', 'triangle', 'mountain',
'half moon', 'dancer', 'eagle']
for kw in standing:
if kw in name_lower:
return 'low'
return 'none'
if 'machine' in mp:
return 'none'
# No impact keywords in exercise name
no_impact_name_kw = ['seated', 'bench', 'lying', 'floor press', 'floor fly',
'foam roll', 'lacrosse', 'incline bench', 'preacher',
'prone', 'supine']
for kw in no_impact_name_kw:
if kw in name_lower:
return 'none'
# Combat: punches/kicks are medium impact
if 'combat' in mp:
if 'kick' in name_lower or 'knee' in name_lower:
return 'medium'
return 'low'
# Cardio with ground contact
if mp == 'cardio/locomotion':
low_impact_cardio = ['bike', 'elliptical', 'rower', 'stair climber', 'swimming']
for kw in low_impact_cardio:
if kw in name_lower:
return 'none'
if 'run' in name_lower or 'jog' in name_lower or 'sprint' in name_lower:
return 'medium'
return 'low'
# Dynamic mobility: low impact
if 'mobility - dynamic' in mp:
return 'low'
# Standing exercises with weight: low impact (feet planted)
if ex.is_weight:
return 'low'
# Core exercises: mostly no impact
if mp.startswith('core'):
if 'carry' in mp:
return 'low' # Walking with load
return 'none'
# Arms: no impact
if mp == 'arms':
return 'none'
# Balance
if 'balance' in mp:
return 'low'
# Standing bodyweight: low
return 'low'
# ── 7. stretch_position (lengthened/mid/shortened/None) ───────────────────
# Based on where peak muscle tension occurs in the range of motion.
# Only set for resistance exercises; null for yoga/mobility/cardio/massage/breathing.
# Lengthened: peak tension when muscle is stretched
LENGTHENED_KEYWORDS = [
'rdl', 'romanian deadlift', 'stiff leg', 'stiff-leg',
'good morning', 'deficit deadlift', 'deficit',
'incline curl', 'incline dumbbell curl', 'incline bicep',
'overhead extension', 'overhead tricep', 'skull crusher', 'skullcrusher',
'french press', 'pullover', 'fly', 'flye', 'cable fly',
'preacher curl', 'spider curl',
'sissy squat', 'nordic',
'hanging leg raise', 'straight arm pulldown',
'dumbbell pull over', 'dumbbell pullover',
]
# Shortened: peak tension when muscle is contracted/shortened
SHORTENED_KEYWORDS = [
'hip thrust', 'glute bridge', 'kickback', 'kick back',
'concentration curl', 'cable curl',
'lateral raise', 'front raise', 'rear delt',
'reverse fly', 'reverse flye', 'face pull',
'calf raise', 'heel raise',
'leg curl', 'hamstring curl', 'leg extension',
'shrug', 'upright row',
'cable crossover', 'pec deck',
'squeeze press', 'superman', 'skydiver',
]
# Null: non-resistance exercises
NULL_STRETCH_PATTERNS = [
'yoga', 'massage', 'breathing', 'mobility', 'cardio/locomotion',
'balance', 'combat',
]
def derive_stretch_position(ex, is_compound):
name_lower = (ex.name or '').lower()
mp = (ex.movement_patterns or '').lower()
# Non-resistance exercises: null
for pattern in NULL_STRETCH_PATTERNS:
if mp.startswith(pattern) or mp == pattern:
return None
# Dynamic mobility: null
if 'mobility' in mp and 'plyometric' not in mp:
return None
# Plyometrics: null (explosive, not about tension position)
if mp == 'plyometric' or mp.startswith('plyometric'):
# Exception: weighted plyometric compound movements
if not ex.is_weight:
return None
# Check lengthened keywords
for kw in LENGTHENED_KEYWORDS:
if kw in name_lower:
return 'lengthened'
# Check shortened keywords
for kw in SHORTENED_KEYWORDS:
if kw in name_lower:
return 'shortened'
# Movement pattern-based defaults for resistance exercises:
# Based on biomechanical research — peak muscle tension position.
# Squats: lengthened — peak tension at "the hole" (bottom), where quads/glutes
# are at greatest length. Sticking point is just above parallel.
if 'squat' in mp and 'squat' in name_lower:
return 'lengthened'
# Deadlifts (standard): lengthened — hardest off the floor where posterior chain
# is maximally stretched. At lockout, tension is minimal.
if 'hip hinge' in mp:
if 'deadlift' in name_lower:
return 'lengthened'
# KB swings, pulls: also lengthened at bottom of hinge
return 'lengthened'
# Bench press / push-ups: mid-range — sticking point ~halfway up, load shared
# between pec, anterior delt, and triceps through the ROM.
if 'upper push - horizontal' in mp:
if 'fly' in name_lower or 'flye' in name_lower:
return 'lengthened' # Pecs peak-loaded at open/stretched position
return 'mid'
# Overhead press: mid-range — sticking point at ~forehead height.
if 'upper push - vertical' in mp:
return 'mid'
# Rows: shortened — back muscles (lats, rhomboids, mid-traps) peak-loaded at top
# with scapulae fully retracted. At bottom (arms extended), load is minimal.
if 'upper pull - horizonal' in mp:
return 'shortened'
# Pull-ups / pulldowns: lengthened — lats maximally loaded at dead hang where
# they're at full stretch. Initiating the pull from hang is the hardest part.
# Research shows bottom half produces superior lat hypertrophy.
if 'upper pull - vertical' in mp:
if 'straight arm' in name_lower:
return 'lengthened'
return 'lengthened'
# Lunges: lengthened (deep stretch on hip flexors/quads at bottom)
if 'lunge' in mp:
return 'lengthened'
# Arms isolation
if mp == 'arms':
if 'curl' in name_lower:
if 'incline' in name_lower or 'preacher' in name_lower or 'spider' in name_lower:
return 'lengthened'
if 'concentration' in name_lower or 'cable' in name_lower:
return 'shortened'
return 'mid'
if 'extension' in name_lower or 'skull' in name_lower:
return 'lengthened'
if 'kickback' in name_lower or 'pushdown' in name_lower or 'push down' in name_lower:
return 'shortened'
return 'mid'
# Core exercises
if mp.startswith('core'):
if 'plank' in name_lower or 'hold' in name_lower:
return 'mid' # Isometric at mid-range position
if 'crunch' in name_lower or 'sit-up' in name_lower or 'sit up' in name_lower:
return 'shortened' # Abs peak-loaded at full flexion
if 'v-up' in name_lower or 'v up' in name_lower or 'bicycle' in name_lower:
return 'shortened' # Same as crunches
if 'superman' in name_lower or 'skydiver' in name_lower:
return 'shortened' # Back extensors peak-loaded at full extension
if 'roll out' in name_lower or 'rollout' in name_lower or 'ab wheel' in name_lower:
return 'lengthened' # Core stretched at full extension
return 'mid' # Carries, rotational, anti-rotation default to mid
# Machine exercises
if 'machine' in mp:
if 'leg extension' in name_lower:
return 'shortened' # Quad peak-loaded at full knee extension (contracted)
if 'leg curl' in name_lower:
return 'shortened' # Hamstrings peak-loaded at full flexion
if 'lat pull' in name_lower:
return 'lengthened' # Lats peak-loaded at top (stretched, like pull-ups)
if 'chest press' in name_lower:
return 'mid'
if 'pec deck' in name_lower or 'fly' in name_lower:
return 'lengthened' # Pecs peak-loaded at open/stretched position
if 'calf raise' in name_lower:
return 'shortened'
if 'row' in name_lower:
return 'shortened' # Same as barbell rows
return 'mid'
# Lower pull general (hip thrusts, glute bridges)
if mp == 'lower pull':
if 'thrust' in name_lower or 'bridge' in name_lower:
return 'shortened'
return 'mid'
# Default for resistance exercises with weight
if ex.is_weight and is_compound:
return 'mid'
return None
# ── 8. progression_of ────────────────────────────────────────────────────
# Only set for clear, verifiable progressions.
# We match by name patterns — e.g., "Band Assisted Pull-Up" → "Pull-Up"
PROGRESSION_MAP = {
'band assisted pull-up (from foot)': 'Pull-Up',
'band assisted pull-up (from knee)': 'Pull-Up',
'band assisted pull up (from foot)': 'Pull-Up',
'band assisted pull up (from knee)': 'Pull-Up',
'band assisted chin-up (from foot)': 'Chin-Up',
'band assisted chin-up (from knee)': 'Chin-Up',
'band assisted chin up (from foot)': 'Chin-Up',
'band assisted chin up (from knee)': 'Chin-Up',
'assisted pull up': 'Pull-Up',
'assisted chin up': 'Chin-Up',
'assisted dip': 'Dip',
'barbell back squat to bench': 'Barbell Back Squat',
'banded push up': 'Push-Up',
'banded push-up': 'Push-Up',
'incline push-up': 'Push-Up',
'incline push up': 'Push-Up',
'knee push-up': 'Push-Up',
'knee push up': 'Push-Up',
}
class Command(BaseCommand):
help = 'Populate the 8 new Exercise fields using rule-based derivation'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run', action='store_true',
help='Show changes without applying',
)
parser.add_argument(
'--verbose', action='store_true',
help='Print every exercise update',
)
parser.add_argument(
'--classification-strategy',
choices=['rules', 'regex'],
default='rules',
help='Classification strategy: "rules" (default) uses movement-pattern logic, '
'"regex" uses name-based regex patterns from classify_exercises.',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
verbose = options['verbose']
strategy = options.get('classification_strategy', 'rules')
exercises = list(Exercise.objects.all())
total = len(exercises)
updated = 0
stats = {
'is_compound': {'True': 0, 'False': 0},
'difficulty_level': {},
'exercise_tier': {},
'complexity_rating': {},
'hr_elevation_rating': {},
'impact_level': {},
'stretch_position': {},
}
# Pre-fetch all exercises for progression_of lookups
name_to_exercise = {}
for ex in exercises:
if ex.name:
name_to_exercise[ex.name] = ex
# Collect exercises to bulk_update instead of saving one at a time
exercises_to_update = []
fields_to_update = set()
for ex in exercises:
if strategy == 'regex':
from generator.management.commands.classify_exercises import classify_exercise
difficulty, complexity = classify_exercise(ex)
# Use rules-based derivation for the remaining fields
is_compound = derive_is_compound(ex)
tier = derive_exercise_tier(ex, is_compound)
hr = derive_hr_elevation(ex, is_compound)
impact = derive_impact_level(ex)
stretch = derive_stretch_position(ex, is_compound)
else:
is_compound = derive_is_compound(ex)
difficulty = derive_difficulty_level(ex)
tier = derive_exercise_tier(ex, is_compound)
complexity = derive_complexity_rating(ex, is_compound)
hr = derive_hr_elevation(ex, is_compound)
impact = derive_impact_level(ex)
stretch = derive_stretch_position(ex, is_compound)
# Progression lookup
progression_target = None
name_lower = (ex.name or '').lower()
if name_lower in PROGRESSION_MAP:
target_name = PROGRESSION_MAP[name_lower]
progression_target = name_to_exercise.get(target_name)
# Track stats
stats['is_compound'][str(is_compound)] = stats['is_compound'].get(str(is_compound), 0) + 1
stats['difficulty_level'][difficulty] = stats['difficulty_level'].get(difficulty, 0) + 1
stats['exercise_tier'][tier] = stats['exercise_tier'].get(tier, 0) + 1
stats['complexity_rating'][str(complexity)] = stats['complexity_rating'].get(str(complexity), 0) + 1
stats['hr_elevation_rating'][str(hr)] = stats['hr_elevation_rating'].get(str(hr), 0) + 1
stats['impact_level'][impact] = stats['impact_level'].get(impact, 0) + 1
stats['stretch_position'][str(stretch)] = stats['stretch_position'].get(str(stretch), 0) + 1
# Check if anything changed
changes = []
if ex.is_compound != is_compound:
changes.append(('is_compound', ex.is_compound, is_compound))
if ex.difficulty_level != difficulty:
changes.append(('difficulty_level', ex.difficulty_level, difficulty))
if ex.exercise_tier != tier:
changes.append(('exercise_tier', ex.exercise_tier, tier))
if ex.complexity_rating != complexity:
changes.append(('complexity_rating', ex.complexity_rating, complexity))
if ex.hr_elevation_rating != hr:
changes.append(('hr_elevation_rating', ex.hr_elevation_rating, hr))
if ex.impact_level != impact:
changes.append(('impact_level', ex.impact_level, impact))
if ex.stretch_position != stretch:
changes.append(('stretch_position', ex.stretch_position, stretch))
if ex.progression_of != progression_target:
changes.append(('progression_of', ex.progression_of, progression_target))
if changes:
if not dry_run:
ex.is_compound = is_compound
ex.difficulty_level = difficulty
ex.exercise_tier = tier
ex.complexity_rating = complexity
ex.hr_elevation_rating = hr
ex.impact_level = impact
ex.stretch_position = stretch
if progression_target:
ex.progression_of = progression_target
exercises_to_update.append(ex)
for field, _, _ in changes:
fields_to_update.add(field)
updated += 1
if verbose:
prefix = '[DRY RUN] ' if dry_run else ''
self.stdout.write(f'{prefix}{ex.name}:')
for field, old, new in changes:
self.stdout.write(f' {field}: {old} -> {new}')
# Bulk update all modified exercises in batches
if exercises_to_update and not dry_run:
Exercise.objects.bulk_update(
exercises_to_update, list(fields_to_update), batch_size=500
)
# Fix #11: Correct is_weight=True on known non-weight exercises
NON_WEIGHT_OVERRIDES = ['wall sit', 'agility ladder', 'plank', 'dead hang', 'l sit']
weight_fixed = 0
for pattern in NON_WEIGHT_OVERRIDES:
qs = Exercise.objects.filter(name__icontains=pattern, is_weight=True)
# Avoid fixing exercises that genuinely use weight (e.g., "weighted plank")
qs = qs.exclude(name__icontains='weighted')
count = qs.count()
if count > 0:
if not dry_run:
qs.update(is_weight=False)
weight_fixed += count
if verbose:
prefix = '[DRY RUN] ' if dry_run else ''
self.stdout.write(f'{prefix}Fixed is_weight for {count} "{pattern}" exercises')
if weight_fixed:
weight_action = 'Would fix' if dry_run else 'Fixed'
self.stdout.write(f'\n{weight_action} is_weight on {weight_fixed} non-weight exercises')
# Print summary
action = 'Would update' if dry_run else 'Updated'
self.stdout.write(f'\n{action} {updated}/{total} exercises\n')
self.stdout.write('\n=== Distribution Summary ===')
for field, dist in stats.items():
self.stdout.write(f'\n{field}:')
for val, count in sorted(dist.items(), key=lambda x: -x[1]):
pct = count / total * 100
self.stdout.write(f' {val}: {count} ({pct:.1f}%)')