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>
This commit is contained in:
Trey t
2026-02-22 20:07:40 -06:00
parent 2a16b75c4b
commit 1c61b80731
111 changed files with 28108 additions and 30 deletions

View File

View File

@@ -0,0 +1,115 @@
"""
Django management command to analyze existing workouts and extract ML patterns.
Usage:
python manage.py analyze_workouts
python manage.py analyze_workouts --dry-run
python manage.py analyze_workouts --verbosity 2
"""
import time
from django.core.management.base import BaseCommand
from generator.services.workout_analyzer import WorkoutAnalyzer
from generator.models import (
MuscleGroupSplit,
MovementPatternOrder,
WeeklySplitPattern,
WorkoutStructureRule,
WorkoutType,
)
class Command(BaseCommand):
help = (
'Analyze existing workouts in the database and extract ML patterns '
'into WorkoutType, MuscleGroupSplit, WeeklySplitPattern, '
'WorkoutStructureRule, and MovementPatternOrder models.'
)
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
default=False,
help='Print what would be done without writing to the database.',
)
def handle(self, *args, **options):
dry_run = options.get('dry_run', False)
if dry_run:
self.stdout.write(self.style.WARNING(
'DRY RUN mode - no changes will be written to the database.\n'
'Remove --dry-run to actually run the analysis.\n'
))
self._print_current_state()
return
start_time = time.time()
analyzer = WorkoutAnalyzer()
analyzer.analyze()
elapsed = time.time() - start_time
self.stdout.write('')
self._print_current_state()
self.stdout.write(self.style.SUCCESS(
f'\nAnalysis complete in {elapsed:.2f}s!'
))
def _print_current_state(self):
"""Print a summary of the current state of all ML pattern models."""
self.stdout.write(self.style.MIGRATE_HEADING('\nCurrent ML Pattern Model State:'))
self.stdout.write(f' WorkoutType: {WorkoutType.objects.count()} records')
self.stdout.write(f' MuscleGroupSplit: {MuscleGroupSplit.objects.count()} records')
self.stdout.write(f' WeeklySplitPattern: {WeeklySplitPattern.objects.count()} records')
self.stdout.write(f' WorkoutStructureRule: {WorkoutStructureRule.objects.count()} records')
self.stdout.write(f' MovementPatternOrder: {MovementPatternOrder.objects.count()} records')
# List WorkoutTypes
wts = WorkoutType.objects.all().order_by('name')
if wts.exists():
self.stdout.write(self.style.MIGRATE_HEADING('\n WorkoutTypes:'))
for wt in wts:
self.stdout.write(
f' - {wt.name}: reps {wt.rep_range_min}-{wt.rep_range_max}, '
f'rounds {wt.round_range_min}-{wt.round_range_max}, '
f'intensity={wt.typical_intensity}'
)
# List MuscleGroupSplits
splits = MuscleGroupSplit.objects.all().order_by('-frequency')
if splits.exists():
self.stdout.write(self.style.MIGRATE_HEADING('\n Top MuscleGroupSplits:'))
for s in splits[:10]:
muscles_str = ', '.join(s.muscle_names[:5])
if len(s.muscle_names) > 5:
muscles_str += f' (+{len(s.muscle_names) - 5} more)'
self.stdout.write(
f' - [{s.split_type}] {s.label} | '
f'freq={s.frequency}, ex_count={s.typical_exercise_count} | '
f'{muscles_str}'
)
# List WeeklySplitPatterns
patterns = WeeklySplitPattern.objects.all().order_by('-frequency')
if patterns.exists():
self.stdout.write(self.style.MIGRATE_HEADING('\n Top WeeklySplitPatterns:'))
for p in patterns[:10]:
self.stdout.write(
f' - {p.days_per_week}-day: {p.pattern_labels} '
f'(freq={p.frequency}, rest_days={p.rest_day_positions})'
)
# List WorkoutStructureRule goal distribution
rules = WorkoutStructureRule.objects.all()
if rules.exists():
from collections import Counter
goal_counts = Counter(rules.values_list('goal_type', flat=True))
self.stdout.write(self.style.MIGRATE_HEADING('\n WorkoutStructureRule by goal:'))
for goal, count in sorted(goal_counts.items()):
self.stdout.write(f' - {goal}: {count} rules')

View File

@@ -0,0 +1,202 @@
"""
Comprehensive audit of exercise data quality.
Checks for:
1. Null estimated_rep_duration on rep-based exercises
2. is_weight false positives (bodyweight exercises marked as weighted)
3. Exercises with no muscle assignments
4. "horizonal" typo in movement_patterns
5. Null metadata fields summary (difficulty_level, exercise_tier, etc.)
Exits with code 1 if any CRITICAL issues are found.
Usage:
python manage.py audit_exercise_data
"""
import re
import sys
from django.core.management.base import BaseCommand
from exercise.models import Exercise
from muscle.models import ExerciseMuscle
# Same bodyweight patterns as fix_exercise_flags for consistency
BODYWEIGHT_PATTERNS = [
r'\bwall sit\b',
r'\bplank\b',
r'\bmountain climber\b',
r'\bburpee\b',
r'\bpush ?up\b',
r'\bpushup\b',
r'\bpull ?up\b',
r'\bpullup\b',
r'\bchin ?up\b',
r'\bchinup\b',
r'\bdips?\b',
r'\bpike\b',
r'\bhandstand\b',
r'\bl sit\b',
r'\bv sit\b',
r'\bhollow\b',
r'\bsuperman\b',
r'\bbird dog\b',
r'\bdead bug\b',
r'\bbear crawl\b',
r'\bcrab walk\b',
r'\binchworm\b',
r'\bjumping jack\b',
r'\bhigh knee\b',
r'\bbutt kick\b',
r'\bskater\b',
r'\blunge jump\b',
r'\bjump lunge\b',
r'\bsquat jump\b',
r'\bjump squat\b',
r'\bbox jump\b',
r'\btuck jump\b',
r'\bbroad jump\b',
r'\bsprinter\b',
r'\bagility ladder\b',
r'\bbody ?weight\b',
r'\bbodyweight\b',
r'\bcalisthenics?\b',
r'\bflutter kick\b',
r'\bleg raise\b',
r'\bsit ?up\b',
r'\bcrunch\b',
r'\bstretch\b',
r'\byoga\b',
r'\bfoam roll\b',
r'\bjump rope\b',
r'\bspider crawl\b',
]
class Command(BaseCommand):
help = 'Audit exercise data quality -- exits 1 if critical issues found'
def handle(self, *args, **options):
issues = []
# 1. Null estimated_rep_duration (excluding duration-only exercises)
null_duration = Exercise.objects.filter(
estimated_rep_duration__isnull=True,
is_reps=True,
).exclude(
is_duration=True, is_reps=False
).count()
if null_duration > 0:
issues.append(
f"CRITICAL: {null_duration} rep-based exercises have null estimated_rep_duration"
)
else:
self.stdout.write(self.style.SUCCESS(
'OK: All rep-based exercises have estimated_rep_duration'
))
# 2. is_weight false positives -- bodyweight exercises marked as weighted
weight_false_positives = 0
weighted_exercises = Exercise.objects.filter(is_weight=True)
for ex in weighted_exercises:
if not ex.name:
continue
name_lower = ex.name.lower()
if any(re.search(pat, name_lower) for pat in BODYWEIGHT_PATTERNS):
weight_false_positives += 1
if weight_false_positives > 0:
issues.append(
f"WARNING: {weight_false_positives} bodyweight exercises still have is_weight=True"
)
else:
self.stdout.write(self.style.SUCCESS(
'OK: No bodyweight exercises incorrectly marked as weighted'
))
# 3. Exercises with no muscles
exercises_with_muscles = set(
ExerciseMuscle.objects.values_list('exercise_id', flat=True).distinct()
)
exercises_no_muscles = Exercise.objects.exclude(
pk__in=exercises_with_muscles
).count()
if exercises_no_muscles > 0:
issues.append(
f"CRITICAL: {exercises_no_muscles} exercises have no muscle assignments"
)
else:
self.stdout.write(self.style.SUCCESS(
'OK: All exercises have muscle assignments'
))
# 4. "horizonal" typo
typo_count = Exercise.objects.filter(
movement_patterns__icontains='horizonal'
).count()
if typo_count > 0:
issues.append(
f'WARNING: {typo_count} exercises have "horizonal" typo in movement_patterns'
)
else:
self.stdout.write(self.style.SUCCESS(
'OK: No "horizonal" typos in movement_patterns'
))
# 5. Null metadata fields summary
total = Exercise.objects.count()
if total > 0:
# Base field always present
metadata_fields = {
'movement_patterns': Exercise.objects.filter(
movement_patterns__isnull=True
).count() + Exercise.objects.filter(movement_patterns='').count(),
}
# Optional fields that may not exist in all environments
optional_fields = ['difficulty_level', 'exercise_tier']
for field_name in optional_fields:
if hasattr(Exercise, field_name):
try:
null_count = Exercise.objects.filter(
**{f'{field_name}__isnull': True}
).count() + Exercise.objects.filter(
**{field_name: ''}
).count()
metadata_fields[field_name] = null_count
except Exception:
pass # Field doesn't exist in DB schema yet
self.stdout.write(f'\nMetadata coverage ({total} total exercises):')
for field, null_count in metadata_fields.items():
filled = total - null_count
pct = (filled / total) * 100
self.stdout.write(f' {field}: {filled}/{total} ({pct:.1f}%)')
if null_count > total * 0.5: # More than 50% missing
issues.append(
f"WARNING: {field} is missing on {null_count}/{total} exercises ({100-pct:.1f}%)"
)
# Report
self.stdout.write('') # blank line
if not issues:
self.stdout.write(self.style.SUCCESS('All exercise data checks passed!'))
else:
for issue in issues:
if issue.startswith('CRITICAL'):
self.stdout.write(self.style.ERROR(issue))
else:
self.stdout.write(self.style.WARNING(issue))
critical = [i for i in issues if i.startswith('CRITICAL')]
if critical:
self.stdout.write(self.style.ERROR(
f'\n{len(critical)} critical issue(s) found. Run fix commands to resolve.'
))
sys.exit(1)
else:
self.stdout.write(self.style.WARNING(
f'\n{len(issues)} non-critical warning(s) found.'
))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
"""
CI management command: check for drift between workout_research.md
calibration values and WorkoutType DB records.
Usage:
python manage.py check_rules_drift
python manage.py check_rules_drift --verbosity 2
"""
import sys
from django.core.management.base import BaseCommand
from generator.models import WorkoutType
from generator.rules_engine import DB_CALIBRATION
class Command(BaseCommand):
help = (
'Check for drift between research doc calibration values '
'and WorkoutType DB records. Exits 1 if mismatches found.'
)
# Fields to compare between DB_CALIBRATION and WorkoutType model
FIELDS_TO_CHECK = [
'duration_bias',
'typical_rest_between_sets',
'typical_intensity',
'rep_range_min',
'rep_range_max',
'round_range_min',
'round_range_max',
'superset_size_min',
'superset_size_max',
]
def handle(self, *args, **options):
verbosity = options.get('verbosity', 1)
mismatches = []
missing_in_db = []
checked = 0
for type_name, expected_values in DB_CALIBRATION.items():
try:
wt = WorkoutType.objects.get(name=type_name)
except WorkoutType.DoesNotExist:
missing_in_db.append(type_name)
continue
for field_name in self.FIELDS_TO_CHECK:
if field_name not in expected_values:
continue
expected = expected_values[field_name]
actual = getattr(wt, field_name, None)
checked += 1
if actual != expected:
mismatches.append({
'type': type_name,
'field': field_name,
'expected': expected,
'actual': actual,
})
elif verbosity >= 2:
self.stdout.write(
f" OK {type_name}.{field_name} = {actual}"
)
# Report results
self.stdout.write('')
self.stdout.write(f'Checked {checked} field(s) across {len(DB_CALIBRATION)} workout types.')
self.stdout.write('')
if missing_in_db:
self.stdout.write(self.style.WARNING(
f'Missing from DB ({len(missing_in_db)}):'
))
for name in missing_in_db:
self.stdout.write(f' - {name}')
self.stdout.write('')
if mismatches:
self.stdout.write(self.style.ERROR(
f'DRIFT DETECTED: {len(mismatches)} mismatch(es)'
))
self.stdout.write('')
header = f'{"Workout Type":<35} {"Field":<30} {"Expected":<15} {"Actual":<15}'
self.stdout.write(header)
self.stdout.write('-' * len(header))
for m in mismatches:
self.stdout.write(
f'{m["type"]:<35} {m["field"]:<30} '
f'{str(m["expected"]):<15} {str(m["actual"]):<15}'
)
self.stdout.write('')
self.stdout.write(self.style.ERROR(
'To fix: update WorkoutType records in the DB or '
'update DB_CALIBRATION in generator/rules_engine.py.'
))
sys.exit(1)
else:
self.stdout.write(self.style.SUCCESS(
'No drift detected. DB values match research calibration.'
))

View File

@@ -0,0 +1,798 @@
"""
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}%)')

View File

@@ -0,0 +1,222 @@
"""
Fix exercise flags and assign missing muscle associations.
1. Fix is_weight flags on exercises that are bodyweight but incorrectly marked
is_weight=True (wall sits, agility ladder, planks, bodyweight exercises, etc.)
2. Assign muscle groups to exercises that have no ExerciseMuscle rows, using
name keyword matching.
Known false positives: wall sits, agility ladder, planks, body weight exercises,
and similar movements that use no external resistance.
Usage:
python manage.py fix_exercise_flags
python manage.py fix_exercise_flags --dry-run
"""
import re
from django.core.management.base import BaseCommand
from exercise.models import Exercise
from muscle.models import Muscle, ExerciseMuscle
try:
from equipment.models import WorkoutEquipment
except ImportError:
WorkoutEquipment = None
# Patterns that indicate bodyweight exercises (no external weight).
# Uses word boundary matching to avoid substring issues (e.g. "l sit" in "wall sit").
BODYWEIGHT_PATTERNS = [
r'\bwall sit\b',
r'\bplank\b',
r'\bmountain climber\b',
r'\bburpee\b',
r'\bpush ?up\b',
r'\bpushup\b',
r'\bpull ?up\b',
r'\bpullup\b',
r'\bchin ?up\b',
r'\bchinup\b',
r'\bdips?\b',
r'\bpike\b',
r'\bhandstand\b',
r'\bl sit\b',
r'\bv sit\b',
r'\bhollow\b',
r'\bsuperman\b',
r'\bbird dog\b',
r'\bdead bug\b',
r'\bbear crawl\b',
r'\bcrab walk\b',
r'\binchworm\b',
r'\bjumping jack\b',
r'\bhigh knee\b',
r'\bbutt kick\b',
r'\bskater\b',
r'\blunge jump\b',
r'\bjump lunge\b',
r'\bsquat jump\b',
r'\bjump squat\b',
r'\bbox jump\b',
r'\btuck jump\b',
r'\bbroad jump\b',
r'\bsprinter\b',
r'\bagility ladder\b',
r'\bbody ?weight\b',
r'\bbodyweight\b',
r'\bcalisthenics?\b',
r'\bflutter kick\b',
r'\bleg raise\b',
r'\bsit ?up\b',
r'\bcrunch\b',
r'\bstretch\b',
r'\byoga\b',
r'\bfoam roll\b',
r'\bjump rope\b',
r'\bspider crawl\b',
]
# Keywords for assigning muscles to exercises with no ExerciseMuscle rows.
# Each muscle name maps to a list of name keywords to match against exercise name.
EXERCISE_MUSCLE_KEYWORDS = {
'chest': ['chest', 'pec', 'bench press', 'push up', 'fly'],
'back': ['back', 'lat', 'row', 'pull up', 'pulldown'],
'shoulders': ['shoulder', 'delt', 'press', 'raise', 'shrug'],
'quads': ['quad', 'squat', 'leg press', 'lunge', 'extension'],
'hamstrings': ['hamstring', 'curl', 'deadlift', 'rdl'],
'glutes': ['glute', 'hip thrust', 'bridge'],
'biceps': ['bicep', 'curl'],
'triceps': ['tricep', 'pushdown', 'extension', 'dip'],
'core': ['core', 'ab', 'crunch', 'plank', 'sit up'],
'calves': ['calf', 'calves', 'calf raise'],
}
class Command(BaseCommand):
help = 'Fix is_weight flags and assign missing muscle associations'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would change without writing to DB',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
self.stdout.write(self.style.MIGRATE_HEADING('Step 1: Fix is_weight false positives'))
weight_fixed = self._fix_is_weight_false_positives(dry_run)
self.stdout.write(self.style.MIGRATE_HEADING('\nStep 2: Assign missing muscles'))
muscle_assigned = self._assign_missing_muscles(dry_run)
prefix = '[DRY RUN] ' if dry_run else ''
self.stdout.write(self.style.SUCCESS(
f'\n{prefix}Summary: Fixed {weight_fixed} is_weight flags, '
f'assigned muscles to {muscle_assigned} exercises'
))
def _fix_is_weight_false_positives(self, dry_run):
"""Fix exercises that are bodyweight but incorrectly marked is_weight=True."""
# Get exercises that have is_weight=True
weighted_exercises = Exercise.objects.filter(is_weight=True)
# Get exercises that have equipment assigned (if WorkoutEquipment exists)
exercises_with_equipment = set()
if WorkoutEquipment is not None:
exercises_with_equipment = set(
WorkoutEquipment.objects.values_list('exercise_id', flat=True).distinct()
)
fixed = 0
for ex in weighted_exercises:
if not ex.name:
continue
name_lower = ex.name.lower()
# Check if name matches any bodyweight pattern
is_bodyweight_name = any(
re.search(pat, name_lower) for pat in BODYWEIGHT_PATTERNS
)
# Also check if the exercise has no equipment assigned
has_no_equipment = ex.pk not in exercises_with_equipment
if is_bodyweight_name and has_no_equipment:
if dry_run:
self.stdout.write(f' Would fix: {ex.name} (id={ex.pk})')
else:
ex.is_weight = False
ex.save(update_fields=['is_weight'])
self.stdout.write(f' Fixed: {ex.name} (id={ex.pk})')
fixed += 1
prefix = '[DRY RUN] ' if dry_run else ''
self.stdout.write(self.style.SUCCESS(
f'{prefix}Fixed {fixed} exercises from is_weight=True to is_weight=False'
))
return fixed
def _assign_missing_muscles(self, dry_run):
"""Assign muscle groups to exercises that have no ExerciseMuscle rows."""
# Find exercises with no muscle associations
exercises_with_muscles = set(
ExerciseMuscle.objects.values_list('exercise_id', flat=True).distinct()
)
orphan_exercises = Exercise.objects.exclude(pk__in=exercises_with_muscles)
if not orphan_exercises.exists():
self.stdout.write(' No exercises without muscle assignments found.')
return 0
self.stdout.write(f' Found {orphan_exercises.count()} exercises without muscle assignments')
# Build a cache of muscle objects by name (case-insensitive)
muscle_cache = {}
for muscle in Muscle.objects.all():
muscle_cache[muscle.name.lower()] = muscle
assigned_count = 0
for ex in orphan_exercises:
if not ex.name:
continue
name_lower = ex.name.lower()
matched_muscles = []
for muscle_name, keywords in EXERCISE_MUSCLE_KEYWORDS.items():
for keyword in keywords:
if keyword in name_lower:
# Find the muscle in the cache
muscle_obj = muscle_cache.get(muscle_name)
if muscle_obj and muscle_obj not in matched_muscles:
matched_muscles.append(muscle_obj)
break # One keyword match per muscle group is enough
if matched_muscles:
if dry_run:
muscle_names = ', '.join(m.name for m in matched_muscles)
self.stdout.write(
f' Would assign: {ex.name} (id={ex.pk}) -> [{muscle_names}]'
)
else:
for muscle_obj in matched_muscles:
ExerciseMuscle.objects.get_or_create(
exercise=ex,
muscle=muscle_obj,
)
muscle_names = ', '.join(m.name for m in matched_muscles)
self.stdout.write(
f' Assigned: {ex.name} (id={ex.pk}) -> [{muscle_names}]'
)
assigned_count += 1
prefix = '[DRY RUN] ' if dry_run else ''
self.stdout.write(self.style.SUCCESS(
f'{prefix}Assigned muscles to {assigned_count} exercises'
))
return assigned_count

View File

@@ -0,0 +1,109 @@
"""
Fix the "horizonal" typo in movement_patterns fields.
The database has "horizonal" (missing 't') instead of "horizontal" in
both Exercise.movement_patterns and MovementPatternOrder.movement_pattern.
This command is idempotent -- running it multiple times is safe.
Usage:
python manage.py fix_movement_pattern_typo --dry-run
python manage.py fix_movement_pattern_typo
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from exercise.models import Exercise
# Import MovementPatternOrder if available (may not exist in test environments)
try:
from generator.models import MovementPatternOrder
except ImportError:
MovementPatternOrder = None
class Command(BaseCommand):
help = 'Fix "horizonal" -> "horizontal" typo in movement_patterns'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would change without writing to DB',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
# Idempotency guard: check if the typo still exists
exercises_with_typo = Exercise.objects.filter(movement_patterns__icontains='horizonal')
has_pattern_typo = False
if MovementPatternOrder is not None:
patterns_with_typo = MovementPatternOrder.objects.filter(
movement_pattern__icontains='horizonal'
)
has_pattern_typo = patterns_with_typo.exists()
if not exercises_with_typo.exists() and not has_pattern_typo:
self.stdout.write(self.style.SUCCESS(
'No "horizonal" typos found -- already fixed.'
))
return
exercise_fixed = 0
pattern_fixed = 0
with transaction.atomic():
# Fix Exercise.movement_patterns
for ex in exercises_with_typo:
old = ex.movement_patterns
new = old.replace('horizonal', 'horizontal')
if old != new:
if dry_run:
self.stdout.write(f' Exercise {ex.pk} "{ex.name}": "{old}" -> "{new}"')
else:
ex.movement_patterns = new
ex.save(update_fields=['movement_patterns'])
exercise_fixed += 1
# Fix MovementPatternOrder.movement_pattern
if MovementPatternOrder is not None:
patterns = MovementPatternOrder.objects.filter(
movement_pattern__icontains='horizonal'
)
for mp in patterns:
old = mp.movement_pattern
new = old.replace('horizonal', 'horizontal')
if old != new:
if dry_run:
self.stdout.write(
f' MovementPatternOrder {mp.pk}: "{old}" -> "{new}"'
)
else:
mp.movement_pattern = new
mp.save(update_fields=['movement_pattern'])
pattern_fixed += 1
if dry_run:
transaction.set_rollback(True)
prefix = '[DRY RUN] ' if dry_run else ''
self.stdout.write(self.style.SUCCESS(
f'\n{prefix}Fixed {exercise_fixed} Exercise records and '
f'{pattern_fixed} MovementPatternOrder records'
))
# Verify
if not dry_run:
remaining = Exercise.objects.filter(
movement_patterns__icontains='horizonal'
).count()
if remaining:
self.stdout.write(self.style.WARNING(
f' WARNING: {remaining} exercises still have "horizonal"'
))
else:
self.stdout.write(self.style.SUCCESS(
' No "horizonal" typos remain.'
))

View File

@@ -0,0 +1,463 @@
"""
Fixes estimated_rep_duration on all Exercise records using three sources:
1. **Exact match** from JSON workout files (AI/all_workouts_data/ and AI/cho/workouts/)
Each set has `estimated_duration` (total seconds) and `reps`.
We compute per_rep = estimated_duration / reps, averaged across all
appearances of each exercise.
2. **Fuzzy match** from the same JSON data for exercises whose DB name
doesn't match exactly. Uses name normalization (strip parentheticals,
punctuation, plurals) + difflib with a 0.85 cutoff, rejecting matches
where the equipment type differs (e.g. barbell vs dumbbell).
3. **Movement-pattern lookup** for exercises not found by either method.
Uses the exercise's `movement_patterns` field against PATTERN_DURATIONS.
4. **Category-based defaults** for exercises that don't match any pattern.
Falls back to DEFAULT_DURATION (3.0s).
Duration-only exercises (is_duration=True AND is_reps=False) are skipped
since they use the `duration` field instead.
Usage:
python manage.py fix_rep_durations
python manage.py fix_rep_durations --dry-run
"""
import difflib
import glob
import json
import os
import re
import statistics
from collections import defaultdict
from django.conf import settings
from django.core.management.base import BaseCommand
from exercise.models import Exercise
# Movement-pattern lookup table: maps movement pattern keywords to per-rep durations.
PATTERN_DURATIONS = {
'compound_push': 3.0,
'compound_pull': 3.0,
'squat': 3.0,
'hinge': 3.0,
'lunge': 3.0,
'isolation_push': 2.5,
'isolation_pull': 2.5,
'isolation': 2.5,
'olympic': 2.0,
'explosive': 2.0,
'plyometric': 2.0,
'carry': 1.0,
'core': 2.5,
}
# Category defaults keyed by substring match on movement_patterns.
# Order matters: first match wins. More specific patterns go first.
CATEGORY_DEFAULTS = [
# Explosive / ballistic -- fast reps
('plyometric', 1.5),
('combat', 1.0),
('cardio/locomotion', 1.0),
# Compound lower -- heavy, slower
('lower pull - hip hinge', 5.0),
('lower push - squat', 4.5),
('lower push - lunge', 4.0),
('lower pull', 4.5),
('lower push', 4.0),
# Compound upper
('upper push - horizontal', 3.5),
('upper push - vertical', 3.5),
('upper pull - vertical', 4.0),
('upper pull - horizonal', 3.5), # note: typo is in DB
('upper pull - horizontal', 3.5), # also match corrected version
('upper push', 3.5),
('upper pull', 3.5),
# Isolation / machine
('machine', 2.5),
('arms', 2.5),
# Core
('core - anti-extension', 3.5),
('core - carry', 3.0),
('core', 3.0),
# Mobility / yoga -- slow, controlled
('yoga', 5.0),
('mobility - static', 5.0),
('mobility - dynamic', 4.0),
('mobility', 4.0),
# Olympic lifts -- explosive, technical
('olympic', 4.0),
# Isolation
('isolation', 2.5),
# Carry / farmer walk
('carry', 3.0),
# Agility
('agility', 1.5),
# Stretch / activation
('stretch', 5.0),
('activation', 3.0),
('warm up', 3.0),
('warmup', 3.0),
]
# Fallback if nothing matches
DEFAULT_DURATION = 3.0
# For backwards compat, also expose as DEFAULT_PER_REP
DEFAULT_PER_REP = DEFAULT_DURATION
# Equipment words -- if these differ between DB and JSON name, reject the match
EQUIPMENT_WORDS = {
'barbell', 'dumbbell', 'kettlebell', 'cable', 'band', 'machine',
'smith', 'trx', 'ez-bar', 'ez bar', 'landmine', 'medicine ball',
'resistance band', 'bodyweight',
}
def _normalize_name(name):
"""Normalize an exercise name for fuzzy comparison."""
n = name.lower().strip()
# Remove parenthetical content: "Squat (Back)" -> "Squat"
n = re.sub(r'\([^)]*\)', '', n)
# Remove common suffixes/noise
n = re.sub(r'\b(each side|per side|each leg|per leg|each arm|per arm)\b', '', n)
# Remove direction words (forward/backward variants are same exercise)
n = re.sub(r'\b(forward|backward|forwards|backwards)\b', '', n)
# Normalize punctuation and whitespace
n = re.sub(r'[^\w\s]', ' ', n)
n = re.sub(r'\s+', ' ', n).strip()
# De-pluralize each word (handles "lunges"->"lunge", "curls"->"curl")
words = []
for w in n.split():
if w.endswith('s') and not w.endswith('ss') and len(w) > 2:
w = w[:-1]
words.append(w)
return ' '.join(words)
def _extract_equipment(name):
"""Extract the equipment word from an exercise name, if any."""
name_lower = name.lower()
for eq in EQUIPMENT_WORDS:
if eq in name_lower:
return eq
return None
class Command(BaseCommand):
help = 'Fix estimated_rep_duration using JSON workout data + pattern/category defaults'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would change without writing to DB',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
# -- Step 1: Parse JSON files for real per-rep timing --
json_durations = self._parse_json_files()
self.stdout.write(
f'Parsed JSON: {len(json_durations)} exercises with real timing data'
)
# -- Step 1b: Build fuzzy lookup from normalized JSON names --
fuzzy_index = self._build_fuzzy_index(json_durations)
# -- Step 2: Update exercises --
exercises = Exercise.objects.all()
from_json_exact = 0
from_json_fuzzy = 0
from_pattern = 0
from_category = 0
skipped_duration_only = 0
set_null = 0
unchanged = 0
fuzzy_matches = []
for ex in exercises:
# Skip duration-only exercises (is_duration=True AND is_reps=False)
if ex.is_duration and not ex.is_reps:
if ex.estimated_rep_duration is not None:
if not dry_run:
ex.estimated_rep_duration = None
ex.save(update_fields=['estimated_rep_duration'])
set_null += 1
else:
skipped_duration_only += 1
continue
# Duration-only exercises that aren't reps-based
if not ex.is_reps and not ex.is_duration:
# Edge case: neither reps nor duration -- skip
unchanged += 1
continue
# Try exact match first
name_lower = ex.name.lower().strip()
if name_lower in json_durations:
new_val = json_durations[name_lower]
source = 'json-exact'
from_json_exact += 1
else:
# Try fuzzy match
fuzzy_result = self._fuzzy_match(ex.name, json_durations, fuzzy_index)
if fuzzy_result is not None:
new_val, matched_name = fuzzy_result
source = 'json-fuzzy'
from_json_fuzzy += 1
fuzzy_matches.append((ex.name, matched_name, new_val))
else:
# Try movement-pattern lookup
pattern_val = self._get_pattern_duration(ex)
if pattern_val is not None:
new_val = pattern_val
source = 'pattern'
from_pattern += 1
else:
# Fall back to category defaults
new_val = self._get_category_default(ex)
source = 'category'
from_category += 1
old_val = ex.estimated_rep_duration
if dry_run:
if old_val != new_val:
self.stdout.write(
f' [{source}] {ex.name}: {old_val:.2f}s -> {new_val:.2f}s'
if old_val else
f' [{source}] {ex.name}: None -> {new_val:.2f}s'
)
else:
ex.estimated_rep_duration = new_val
ex.save(update_fields=['estimated_rep_duration'])
self.stdout.write(self.style.SUCCESS(
f'\n{"[DRY RUN] " if dry_run else ""}'
f'Updated {from_json_exact + from_json_fuzzy + from_pattern + from_category + set_null} exercises: '
f'{from_json_exact} from JSON (exact), {from_json_fuzzy} from JSON (fuzzy), '
f'{from_pattern} from pattern lookup, {from_category} from category defaults, '
f'{set_null} set to null (duration-only), '
f'{skipped_duration_only} already null (duration-only), '
f'{unchanged} unchanged'
))
# Show fuzzy matches for review
if fuzzy_matches:
self.stdout.write(f'\nFuzzy matches ({len(fuzzy_matches)}):')
for db_name, json_name, val in sorted(fuzzy_matches):
self.stdout.write(f' {db_name:50s} -> {json_name} ({val:.2f}s)')
# -- Step 3: Show summary stats --
reps_exercises = Exercise.objects.filter(is_reps=True)
total_reps = reps_exercises.count()
with_duration = reps_exercises.exclude(estimated_rep_duration__isnull=True).count()
without_duration = reps_exercises.filter(estimated_rep_duration__isnull=True).count()
coverage_pct = (with_duration / total_reps * 100) if total_reps > 0 else 0
self.stdout.write(
f'\nCoverage: {with_duration}/{total_reps} rep-based exercises '
f'have estimated_rep_duration ({coverage_pct:.1f}%)'
)
if without_duration > 0:
self.stdout.write(
f' {without_duration} exercises still missing estimated_rep_duration'
)
if not dry_run:
durations = list(
reps_exercises
.exclude(estimated_rep_duration__isnull=True)
.values_list('estimated_rep_duration', flat=True)
)
if durations:
self.stdout.write(
f'\nNew stats for rep-based exercises ({len(durations)}):'
f'\n Min: {min(durations):.2f}s'
f'\n Max: {max(durations):.2f}s'
f'\n Mean: {statistics.mean(durations):.2f}s'
f'\n Median: {statistics.median(durations):.2f}s'
)
def _build_fuzzy_index(self, json_durations):
"""
Build a dict of {normalized_name: original_name} for fuzzy matching.
"""
index = {}
for original_name in json_durations:
norm = _normalize_name(original_name)
# Keep the first occurrence if duplicates after normalization
if norm not in index:
index[norm] = original_name
return index
def _fuzzy_match(self, db_name, json_durations, fuzzy_index):
"""
Try to fuzzy-match a DB exercise name to a JSON exercise name.
Strategy:
1. Exact match on normalized names
2. Containment match: all words of the shorter name appear in the longer
3. High-cutoff difflib (0.88) with word overlap >= 75%
Equipment must match in all cases.
Returns (duration_value, matched_json_name) or None.
"""
db_norm = _normalize_name(db_name)
db_equipment = _extract_equipment(db_name)
db_words = set(db_norm.split())
# First try: exact match on normalized names
if db_norm in fuzzy_index:
original = fuzzy_index[db_norm]
json_equipment = _extract_equipment(original)
if db_equipment and json_equipment and db_equipment != json_equipment:
return None
return json_durations[original], original
# Second try: containment match -- shorter name's words are a
# subset of the longer name's words (e.g. "barbell good morning"
# is contained in "barbell russian good morning")
for json_norm, original in fuzzy_index.items():
json_words = set(json_norm.split())
shorter, longer = (
(db_words, json_words) if len(db_words) <= len(json_words)
else (json_words, db_words)
)
# All words of the shorter must appear in the longer
if shorter.issubset(longer) and len(shorter) >= 2:
# But names shouldn't differ by too many words (max 2 extra)
if len(longer) - len(shorter) > 2:
continue
json_equipment = _extract_equipment(original)
if db_equipment and json_equipment and db_equipment != json_equipment:
continue
if (db_equipment is None) != (json_equipment is None):
continue
return json_durations[original], original
# Third try: high-cutoff difflib with strict word overlap
normalized_json_names = list(fuzzy_index.keys())
matches = difflib.get_close_matches(
db_norm, normalized_json_names, n=3, cutoff=0.88,
)
for match_norm in matches:
original = fuzzy_index[match_norm]
json_equipment = _extract_equipment(original)
if db_equipment and json_equipment and db_equipment != json_equipment:
continue
if (db_equipment is None) != (json_equipment is None):
continue
# Require >= 75% word overlap
match_words = set(match_norm.split())
overlap = len(db_words & match_words)
total = max(len(db_words), len(match_words))
if total > 0 and overlap / total < 0.75:
continue
return json_durations[original], original
return None
def _parse_json_files(self):
"""
Parse all workout JSON files and compute average per-rep duration
for each exercise. Returns {lowercase_name: avg_seconds_per_rep}.
"""
base = settings.BASE_DIR
patterns = [
os.path.join(base, 'AI', 'all_workouts_data', '*.json'),
os.path.join(base, 'AI', 'cho', 'workouts', '*.json'),
]
files = []
for pat in patterns:
files.extend(sorted(glob.glob(pat)))
exercise_samples = defaultdict(list)
for fpath in files:
with open(fpath) as f:
try:
data = json.load(f)
except (json.JSONDecodeError, UnicodeDecodeError):
continue
workouts = [data] if isinstance(data, dict) else data
for workout in workouts:
if not isinstance(workout, dict):
continue
for section in workout.get('sections', []):
for s in section.get('sets', []):
if not isinstance(s, dict):
continue
ex = s.get('exercise', {})
if not isinstance(ex, dict):
continue
name = ex.get('name', '').strip()
if not name:
continue
reps = s.get('reps', 0) or 0
est_dur = s.get('estimated_duration', 0) or 0
set_type = s.get('type', '')
if set_type == 'reps' and reps > 0 and est_dur > 0:
per_rep = est_dur / reps
# Sanity: ignore outliers (< 0.5s or > 20s per rep)
if 0.5 <= per_rep <= 20.0:
exercise_samples[name.lower()].append(per_rep)
# Average across all samples per exercise
result = {}
for name, samples in exercise_samples.items():
result[name] = round(statistics.mean(samples), 2)
return result
def _get_pattern_duration(self, exercise):
"""
Return a per-rep duration based on the PATTERN_DURATIONS lookup table.
Checks the exercise's movement_patterns field for matching patterns.
Returns the first match, or None if no match.
"""
patterns_str = (exercise.movement_patterns or '').lower()
if not patterns_str:
return None
for pattern_key, duration in PATTERN_DURATIONS.items():
if pattern_key in patterns_str:
return duration
return None
def _get_category_default(self, exercise):
"""
Return a per-rep duration based on the exercise's movement_patterns
using the more detailed CATEGORY_DEFAULTS table.
"""
patterns = (exercise.movement_patterns or '').lower()
for keyword, duration in CATEGORY_DEFAULTS:
if keyword in patterns:
return duration
return DEFAULT_DURATION

View File

@@ -0,0 +1,116 @@
"""
Normalize muscle names in the database and merge duplicates.
Uses the MUSCLE_NORMALIZATION_MAP from muscle_normalizer.py to:
1. Rename each Muscle record to its canonical lowercase form
2. Merge duplicates by updating ExerciseMuscle FKs to point to the canonical Muscle
3. Delete orphaned duplicate Muscle records
Usage:
python manage.py normalize_muscle_names --dry-run
python manage.py normalize_muscle_names
"""
from collections import defaultdict
from django.core.management.base import BaseCommand
from django.db import transaction
from muscle.models import Muscle, ExerciseMuscle
from generator.services.muscle_normalizer import normalize_muscle_name
class Command(BaseCommand):
help = 'Normalize muscle names and merge duplicates using MUSCLE_NORMALIZATION_MAP'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would change without writing to DB',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
all_muscles = Muscle.objects.all().order_by('id')
self.stdout.write(f'Found {all_muscles.count()} muscle records')
# Group muscles by their canonical name
canonical_groups = defaultdict(list)
for muscle in all_muscles:
canonical = normalize_muscle_name(muscle.name)
if canonical:
canonical_groups[canonical].append(muscle)
renamed = 0
merged = 0
deleted = 0
with transaction.atomic():
for canonical_name, muscles in canonical_groups.items():
# Pick the keeper: prefer the one with the lowest ID (oldest)
keeper = muscles[0]
# Rename keeper if needed
if keeper.name != canonical_name:
if dry_run:
self.stdout.write(f' Rename: "{keeper.name}" -> "{canonical_name}" (id={keeper.pk})')
else:
keeper.name = canonical_name
keeper.save(update_fields=['name'])
renamed += 1
# Merge duplicates into keeper
for dup in muscles[1:]:
# Count affected ExerciseMuscle rows
em_count = ExerciseMuscle.objects.filter(muscle=dup).count()
if dry_run:
self.stdout.write(
f' Merge: "{dup.name}" (id={dup.pk}) -> "{canonical_name}" '
f'(id={keeper.pk}), {em_count} ExerciseMuscle rows'
)
else:
# Update ExerciseMuscle FKs, handling unique_together conflicts
for em in ExerciseMuscle.objects.filter(muscle=dup):
# Check if keeper already has this exercise
existing = ExerciseMuscle.objects.filter(
exercise=em.exercise, muscle=keeper
).exists()
if existing:
em.delete()
else:
em.muscle = keeper
em.save(update_fields=['muscle'])
dup.delete()
merged += em_count
deleted += 1
if dry_run:
# Roll back the transaction for dry run
transaction.set_rollback(True)
prefix = '[DRY RUN] ' if dry_run else ''
self.stdout.write(self.style.SUCCESS(
f'\n{prefix}Results:'
f'\n Renamed: {renamed} muscles'
f'\n Merged: {merged} ExerciseMuscle references'
f'\n Deleted: {deleted} duplicate Muscle records'
))
# Verify
if not dry_run:
dupes = (
Muscle.objects.values('name')
.annotate(c=__import__('django.db.models', fromlist=['Count']).Count('id'))
.filter(c__gt=1)
)
if dupes.exists():
self.stdout.write(self.style.WARNING(
f' WARNING: {dupes.count()} duplicate names still exist!'
))
else:
self.stdout.write(self.style.SUCCESS(' No duplicate muscle names remain.'))

View File

@@ -0,0 +1,130 @@
"""
Management command to normalize muscle names in the database.
Fixes casing duplicates (e.g. "Quads" vs "quads") and updates
ExerciseMuscle records to point to the canonical muscle entries.
Usage:
python manage.py normalize_muscles # apply changes
python manage.py normalize_muscles --dry-run # preview only
"""
from django.core.management.base import BaseCommand
from muscle.models import Muscle, ExerciseMuscle
from generator.services.muscle_normalizer import normalize_muscle_name
class Command(BaseCommand):
help = 'Normalize muscle names (fix casing duplicates) and consolidate ExerciseMuscle records.'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Preview changes without modifying the database.',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN - no changes will be made.\n'))
muscles = Muscle.objects.all().order_by('name')
self.stdout.write(f'Total muscles in DB: {muscles.count()}\n')
# Build a mapping: canonical_name -> list of Muscle objects with that canonical name
canonical_map = {}
for m in muscles:
canonical = normalize_muscle_name(m.name)
if canonical is None:
canonical = m.name.strip().lower()
canonical_map.setdefault(canonical, []).append(m)
# Identify duplicates (canonical names with > 1 Muscle record)
duplicates = {k: v for k, v in canonical_map.items() if len(v) > 1}
if not duplicates:
self.stdout.write(self.style.SUCCESS('No duplicate muscles found. Nothing to normalize.'))
return
self.stdout.write(f'Found {len(duplicates)} canonical names with duplicates:\n')
merged_count = 0
reassigned_count = 0
for canonical, muscle_list in sorted(duplicates.items()):
names = [m.name for m in muscle_list]
self.stdout.write(f'\n "{canonical}" <- {names}')
# Keep the first one (or the one whose name already matches canonical)
keep = None
for m in muscle_list:
if m.name == canonical:
keep = m
break
if keep is None:
keep = muscle_list[0]
to_merge = [m for m in muscle_list if m.pk != keep.pk]
for old_muscle in to_merge:
# Reassign ExerciseMuscle records from old_muscle to keep
em_records = ExerciseMuscle.objects.filter(muscle=old_muscle)
count = em_records.count()
if count > 0:
self.stdout.write(f' Reassigning {count} ExerciseMuscle records: '
f'"{old_muscle.name}" (id={old_muscle.pk}) -> '
f'"{keep.name}" (id={keep.pk})')
if not dry_run:
# Check for conflicts (same exercise already linked to keep)
for em in em_records:
existing = ExerciseMuscle.objects.filter(
exercise=em.exercise, muscle=keep
).exists()
if existing:
em.delete()
else:
em.muscle = keep
em.save()
reassigned_count += count
# Rename keep to canonical if needed
if keep.name != canonical and not dry_run:
keep.name = canonical
keep.save()
# Delete the duplicate
self.stdout.write(f' Deleting duplicate: "{old_muscle.name}" (id={old_muscle.pk})')
if not dry_run:
old_muscle.delete()
merged_count += 1
# Also fix names that aren't duplicates but have wrong casing
rename_count = 0
for canonical, muscle_list in canonical_map.items():
if len(muscle_list) == 1:
m = muscle_list[0]
if m.name != canonical:
self.stdout.write(f'\n Renaming: "{m.name}" -> "{canonical}"')
if not dry_run:
m.name = canonical
m.save()
rename_count += 1
self.stdout.write('\n')
if dry_run:
self.stdout.write(self.style.WARNING(
f'DRY RUN complete. Would merge {merged_count} duplicates, '
f'reassign {reassigned_count} ExerciseMuscle records, '
f'rename {rename_count} muscles.'
))
else:
remaining = Muscle.objects.count()
self.stdout.write(self.style.SUCCESS(
f'Done. Merged {merged_count} duplicates, '
f'reassigned {reassigned_count} ExerciseMuscle records, '
f'renamed {rename_count} muscles. '
f'{remaining} muscles remaining.'
))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
"""
Recalculates estimated_time on all Workout and Superset records using
the corrected estimated_rep_duration values + rest between rounds.
Formula per superset:
active_time = sum(reps * exercise.estimated_rep_duration) + sum(durations)
rest_time = rest_between_rounds * (rounds - 1)
superset.estimated_time = active_time (stores single-round active time)
Formula per workout:
workout.estimated_time = sum(superset_active_time * rounds + rest_time)
Usage:
python manage.py recalculate_workout_times
python manage.py recalculate_workout_times --dry-run
python manage.py recalculate_workout_times --rest=45
"""
from django.core.management.base import BaseCommand
from workout.models import Workout
from superset.models import Superset, SupersetExercise
DEFAULT_REST_BETWEEN_ROUNDS = 45 # seconds
DEFAULT_REP_DURATION = 3.0 # fallback if null
class Command(BaseCommand):
help = 'Recalculate estimated_time on all Workouts and Supersets'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show changes without writing to DB',
)
parser.add_argument(
'--rest',
type=int,
default=DEFAULT_REST_BETWEEN_ROUNDS,
help=f'Rest between rounds in seconds (default: {DEFAULT_REST_BETWEEN_ROUNDS})',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
rest_between_rounds = options['rest']
workouts = Workout.objects.all()
total = workouts.count()
updated = 0
for workout in workouts:
supersets = Superset.objects.filter(workout=workout).order_by('order')
workout_total_time = 0
for ss in supersets:
exercises = SupersetExercise.objects.filter(superset=ss)
active_time = 0.0
for se in exercises:
if se.reps and se.reps > 0:
rep_dur = se.exercise.estimated_rep_duration or DEFAULT_REP_DURATION
active_time += se.reps * rep_dur
elif se.duration and se.duration > 0:
active_time += se.duration
# Rest between rounds (not after the last round)
rest_time = rest_between_rounds * max(0, ss.rounds - 1)
# Superset stores single-round active time
old_ss_time = ss.estimated_time
ss.estimated_time = active_time
if not dry_run:
ss.save(update_fields=['estimated_time'])
# Workout accumulates: active per round * rounds + rest
workout_total_time += (active_time * ss.rounds) + rest_time
old_time = workout.estimated_time
new_time = workout_total_time
if not dry_run:
workout.estimated_time = new_time
workout.save(update_fields=['estimated_time'])
updated += 1
self.stdout.write(self.style.SUCCESS(
f'{"[DRY RUN] " if dry_run else ""}'
f'Recalculated {updated}/{total} workouts '
f'(rest between rounds: {rest_between_rounds}s)'
))
# Show some examples
if not dry_run:
self.stdout.write('\nSample workouts:')
for w in Workout.objects.order_by('-id')[:5]:
mins = w.estimated_time / 60 if w.estimated_time else 0
ss_count = Superset.objects.filter(workout=w).count()
ex_count = SupersetExercise.objects.filter(superset__workout=w).count()
self.stdout.write(
f' #{w.id} "{w.name}": {mins:.0f}m '
f'({ss_count} supersets, {ex_count} exercises)'
)