- 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>
250 lines
9.2 KiB
Python
250 lines
9.2 KiB
Python
from django.core.validators import MinValueValidator, MaxValueValidator
|
|
from django.db import models
|
|
from registered_user.models import RegisteredUser
|
|
from workout.models import Workout
|
|
from exercise.models import Exercise
|
|
from equipment.models import Equipment
|
|
from muscle.models import Muscle
|
|
|
|
|
|
INTENSITY_CHOICES = (
|
|
('low', 'Low'),
|
|
('medium', 'Medium'),
|
|
('high', 'High'),
|
|
)
|
|
|
|
GOAL_CHOICES = (
|
|
('strength', 'Strength'),
|
|
('hypertrophy', 'Hypertrophy'),
|
|
('endurance', 'Endurance'),
|
|
('weight_loss', 'Weight Loss'),
|
|
('general_fitness', 'General Fitness'),
|
|
)
|
|
|
|
FITNESS_LEVEL_CHOICES = (
|
|
(1, 'Beginner'),
|
|
(2, 'Intermediate'),
|
|
(3, 'Advanced'),
|
|
(4, 'Elite'),
|
|
)
|
|
|
|
PLAN_STATUS_CHOICES = (
|
|
('pending', 'Pending'),
|
|
('completed', 'Completed'),
|
|
('failed', 'Failed'),
|
|
)
|
|
|
|
WORKOUT_STATUS_CHOICES = (
|
|
('pending', 'Pending'),
|
|
('accepted', 'Accepted'),
|
|
('rejected', 'Rejected'),
|
|
('completed', 'Completed'),
|
|
)
|
|
|
|
SPLIT_TYPE_CHOICES = (
|
|
('push', 'Push'),
|
|
('pull', 'Pull'),
|
|
('legs', 'Legs'),
|
|
('upper', 'Upper'),
|
|
('lower', 'Lower'),
|
|
('full_body', 'Full Body'),
|
|
('core', 'Core'),
|
|
('cardio', 'Cardio'),
|
|
)
|
|
|
|
SECTION_TYPE_CHOICES = (
|
|
('warm_up', 'Warm Up'),
|
|
('working', 'Working'),
|
|
('cool_down', 'Cool Down'),
|
|
)
|
|
|
|
POSITION_CHOICES = (
|
|
('early', 'Early'),
|
|
('middle', 'Middle'),
|
|
('late', 'Late'),
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# Reference / Config Models
|
|
# ============================================================
|
|
|
|
class WorkoutType(models.Model):
|
|
name = models.CharField(max_length=100, unique=True)
|
|
display_name = models.CharField(max_length=100, blank=True, default='')
|
|
description = models.TextField(blank=True, default='')
|
|
typical_rest_between_sets = models.IntegerField(default=60, help_text='Seconds')
|
|
typical_intensity = models.CharField(max_length=10, choices=INTENSITY_CHOICES, default='medium')
|
|
rep_range_min = models.IntegerField(default=8)
|
|
rep_range_max = models.IntegerField(default=12)
|
|
round_range_min = models.IntegerField(default=3)
|
|
round_range_max = models.IntegerField(default=4)
|
|
duration_bias = models.FloatField(default=0.5, help_text='0.0=all rep-based, 1.0=all duration-based')
|
|
superset_size_min = models.IntegerField(default=2)
|
|
superset_size_max = models.IntegerField(default=4)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
# ============================================================
|
|
# User Preference Model
|
|
# ============================================================
|
|
|
|
class UserPreference(models.Model):
|
|
registered_user = models.OneToOneField(
|
|
RegisteredUser,
|
|
on_delete=models.CASCADE,
|
|
related_name='generator_preference'
|
|
)
|
|
available_equipment = models.ManyToManyField(Equipment, blank=True, related_name='user_preferences')
|
|
target_muscle_groups = models.ManyToManyField(Muscle, blank=True, related_name='user_preferences')
|
|
preferred_workout_types = models.ManyToManyField(WorkoutType, blank=True, related_name='user_preferences')
|
|
fitness_level = models.IntegerField(choices=FITNESS_LEVEL_CHOICES, default=2)
|
|
primary_goal = models.CharField(max_length=20, choices=GOAL_CHOICES, default='general_fitness')
|
|
secondary_goal = models.CharField(max_length=20, choices=GOAL_CHOICES, blank=True, default='')
|
|
days_per_week = models.IntegerField(default=4)
|
|
preferred_workout_duration = models.IntegerField(
|
|
default=45, help_text='Minutes',
|
|
validators=[MinValueValidator(15), MaxValueValidator(120)],
|
|
)
|
|
preferred_days = models.JSONField(default=list, blank=True, help_text='List of weekday ints (0=Mon, 6=Sun)')
|
|
injuries_limitations = models.TextField(blank=True, default='')
|
|
injury_types = models.JSONField(
|
|
default=list, blank=True,
|
|
help_text='Structured injury types: knee, lower_back, upper_back, shoulder, hip, wrist, ankle, neck',
|
|
)
|
|
excluded_exercises = models.ManyToManyField(Exercise, blank=True, related_name='excluded_by_users')
|
|
|
|
def __str__(self):
|
|
return f"Preferences for {self.registered_user.first_name}"
|
|
|
|
|
|
# ============================================================
|
|
# Generated Plan / Workout Models
|
|
# ============================================================
|
|
|
|
class GeneratedWeeklyPlan(models.Model):
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
registered_user = models.ForeignKey(
|
|
RegisteredUser,
|
|
on_delete=models.CASCADE,
|
|
related_name='generated_plans'
|
|
)
|
|
week_start_date = models.DateField()
|
|
week_end_date = models.DateField()
|
|
status = models.CharField(max_length=10, choices=PLAN_STATUS_CHOICES, default='pending')
|
|
preferences_snapshot = models.JSONField(default=dict, blank=True)
|
|
generation_time_ms = models.IntegerField(null=True, blank=True)
|
|
|
|
# Periodization fields
|
|
week_number = models.IntegerField(default=1, help_text='Position in training cycle (1-based)')
|
|
is_deload = models.BooleanField(default=False, help_text='Whether this is a recovery/deload week')
|
|
cycle_id = models.CharField(max_length=64, null=True, blank=True, help_text='Groups weeks into training cycles')
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
|
|
def __str__(self):
|
|
return f"Plan {self.id} for {self.registered_user.first_name} ({self.week_start_date})"
|
|
|
|
|
|
class GeneratedWorkout(models.Model):
|
|
plan = models.ForeignKey(
|
|
GeneratedWeeklyPlan,
|
|
on_delete=models.CASCADE,
|
|
related_name='generated_workouts'
|
|
)
|
|
workout = models.OneToOneField(
|
|
Workout,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='generated_from'
|
|
)
|
|
workout_type = models.ForeignKey(
|
|
WorkoutType,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='generated_workouts'
|
|
)
|
|
scheduled_date = models.DateField()
|
|
day_of_week = models.IntegerField(help_text='0=Monday, 6=Sunday')
|
|
is_rest_day = models.BooleanField(default=False)
|
|
status = models.CharField(max_length=10, choices=WORKOUT_STATUS_CHOICES, default='pending')
|
|
focus_area = models.CharField(max_length=255, blank=True, default='')
|
|
target_muscles = models.JSONField(default=list, blank=True)
|
|
user_rating = models.IntegerField(null=True, blank=True)
|
|
user_feedback = models.TextField(blank=True, default='')
|
|
|
|
class Meta:
|
|
ordering = ['scheduled_date']
|
|
|
|
def __str__(self):
|
|
if self.is_rest_day:
|
|
return f"Rest Day - {self.scheduled_date}"
|
|
return f"{self.focus_area} - {self.scheduled_date}"
|
|
|
|
|
|
# ============================================================
|
|
# ML Pattern Models (populated by analyze_workouts command)
|
|
# ============================================================
|
|
|
|
class MuscleGroupSplit(models.Model):
|
|
muscle_names = models.JSONField(default=list, help_text='List of muscle group names')
|
|
frequency = models.IntegerField(default=0, help_text='How often this combo appeared')
|
|
label = models.CharField(max_length=100, blank=True, default='')
|
|
typical_exercise_count = models.IntegerField(default=6)
|
|
split_type = models.CharField(max_length=20, choices=SPLIT_TYPE_CHOICES, default='full_body')
|
|
|
|
def __str__(self):
|
|
return f"{self.label} ({self.split_type}) - freq: {self.frequency}"
|
|
|
|
|
|
class WeeklySplitPattern(models.Model):
|
|
days_per_week = models.IntegerField()
|
|
pattern = models.JSONField(default=list, help_text='Ordered list of MuscleGroupSplit IDs')
|
|
pattern_labels = models.JSONField(default=list, help_text='Ordered list of split labels')
|
|
frequency = models.IntegerField(default=0)
|
|
rest_day_positions = models.JSONField(default=list, help_text='Day indices that are rest days')
|
|
|
|
def __str__(self):
|
|
return f"{self.days_per_week}-day split (freq: {self.frequency}): {self.pattern_labels}"
|
|
|
|
|
|
class WorkoutStructureRule(models.Model):
|
|
workout_type = models.ForeignKey(
|
|
WorkoutType,
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name='structure_rules'
|
|
)
|
|
section_type = models.CharField(max_length=10, choices=SECTION_TYPE_CHOICES)
|
|
movement_patterns = models.JSONField(default=list)
|
|
typical_rounds = models.IntegerField(default=3)
|
|
typical_exercises_per_superset = models.IntegerField(default=3)
|
|
typical_rep_range_min = models.IntegerField(default=8)
|
|
typical_rep_range_max = models.IntegerField(default=12)
|
|
typical_duration_range_min = models.IntegerField(default=30, help_text='Seconds')
|
|
typical_duration_range_max = models.IntegerField(default=45, help_text='Seconds')
|
|
goal_type = models.CharField(max_length=20, choices=GOAL_CHOICES, default='general_fitness')
|
|
|
|
def __str__(self):
|
|
wt = self.workout_type.name if self.workout_type else 'Any'
|
|
return f"{wt} - {self.section_type} ({self.goal_type})"
|
|
|
|
|
|
class MovementPatternOrder(models.Model):
|
|
position = models.CharField(max_length=10, choices=POSITION_CHOICES)
|
|
movement_pattern = models.CharField(max_length=100)
|
|
frequency = models.IntegerField(default=0)
|
|
section_type = models.CharField(max_length=10, choices=SECTION_TYPE_CHOICES, default='working')
|
|
|
|
class Meta:
|
|
ordering = ['position', '-frequency']
|
|
|
|
def __str__(self):
|
|
return f"{self.movement_pattern} @ {self.position} (freq: {self.frequency})"
|