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>
1055 lines
38 KiB
Python
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}%)')
|