- 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>
377 lines
13 KiB
Python
377 lines
13 KiB
Python
from rest_framework import serializers
|
|
from .models import (
|
|
WorkoutType,
|
|
UserPreference,
|
|
GeneratedWeeklyPlan,
|
|
GeneratedWorkout,
|
|
MuscleGroupSplit,
|
|
)
|
|
from muscle.models import Muscle
|
|
from equipment.models import Equipment
|
|
from exercise.models import Exercise
|
|
from workout.serializers import WorkoutDetailSerializer
|
|
from superset.serializers import SupersetSerializer
|
|
from superset.models import Superset
|
|
|
|
|
|
# ============================================================
|
|
# Reference Serializers (for preference UI dropdowns)
|
|
# ============================================================
|
|
|
|
class MuscleSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = Muscle
|
|
fields = ('id', 'name')
|
|
|
|
|
|
class EquipmentSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = Equipment
|
|
fields = ('id', 'name', 'category')
|
|
|
|
|
|
# ============================================================
|
|
# WorkoutType Serializer
|
|
# ============================================================
|
|
|
|
class WorkoutTypeSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = WorkoutType
|
|
fields = '__all__'
|
|
|
|
|
|
# ============================================================
|
|
# UserPreference Serializers
|
|
# ============================================================
|
|
|
|
class UserPreferenceSerializer(serializers.ModelSerializer):
|
|
"""Read serializer -- returns nested names for M2M fields."""
|
|
available_equipment = EquipmentSerializer(many=True, read_only=True)
|
|
target_muscle_groups = MuscleSerializer(many=True, read_only=True)
|
|
preferred_workout_types = WorkoutTypeSerializer(many=True, read_only=True)
|
|
excluded_exercises = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = UserPreference
|
|
fields = (
|
|
'id',
|
|
'registered_user',
|
|
'available_equipment',
|
|
'target_muscle_groups',
|
|
'preferred_workout_types',
|
|
'fitness_level',
|
|
'primary_goal',
|
|
'secondary_goal',
|
|
'days_per_week',
|
|
'preferred_workout_duration',
|
|
'preferred_days',
|
|
'injuries_limitations',
|
|
'injury_types',
|
|
'excluded_exercises',
|
|
)
|
|
read_only_fields = ('id', 'registered_user')
|
|
|
|
def get_excluded_exercises(self, obj):
|
|
return list(
|
|
obj.excluded_exercises.values_list('id', flat=True)
|
|
)
|
|
|
|
|
|
class UserPreferenceUpdateSerializer(serializers.ModelSerializer):
|
|
"""Write serializer -- accepts IDs for M2M fields."""
|
|
equipment_ids = serializers.PrimaryKeyRelatedField(
|
|
queryset=Equipment.objects.all(),
|
|
many=True,
|
|
required=False,
|
|
source='available_equipment',
|
|
)
|
|
muscle_ids = serializers.PrimaryKeyRelatedField(
|
|
queryset=Muscle.objects.all(),
|
|
many=True,
|
|
required=False,
|
|
source='target_muscle_groups',
|
|
)
|
|
workout_type_ids = serializers.PrimaryKeyRelatedField(
|
|
queryset=WorkoutType.objects.all(),
|
|
many=True,
|
|
required=False,
|
|
source='preferred_workout_types',
|
|
)
|
|
excluded_exercise_ids = serializers.PrimaryKeyRelatedField(
|
|
queryset=Exercise.objects.all(),
|
|
many=True,
|
|
required=False,
|
|
source='excluded_exercises',
|
|
)
|
|
|
|
class Meta:
|
|
model = UserPreference
|
|
fields = (
|
|
'equipment_ids',
|
|
'muscle_ids',
|
|
'workout_type_ids',
|
|
'fitness_level',
|
|
'primary_goal',
|
|
'secondary_goal',
|
|
'days_per_week',
|
|
'preferred_workout_duration',
|
|
'preferred_days',
|
|
'injuries_limitations',
|
|
'injury_types',
|
|
'excluded_exercise_ids',
|
|
)
|
|
|
|
VALID_INJURY_TYPES = {
|
|
'knee', 'lower_back', 'upper_back', 'shoulder',
|
|
'hip', 'wrist', 'ankle', 'neck',
|
|
}
|
|
VALID_SEVERITY_LEVELS = {'mild', 'moderate', 'severe'}
|
|
|
|
def validate_injury_types(self, value):
|
|
if not isinstance(value, list):
|
|
raise serializers.ValidationError('injury_types must be a list.')
|
|
|
|
normalized = []
|
|
seen = set()
|
|
for item in value:
|
|
# Backward compat: plain string -> {"type": str, "severity": "moderate"}
|
|
if isinstance(item, str):
|
|
injury_type = item
|
|
severity = 'moderate'
|
|
elif isinstance(item, dict):
|
|
injury_type = item.get('type', '')
|
|
severity = item.get('severity', 'moderate')
|
|
else:
|
|
raise serializers.ValidationError(
|
|
'Each injury must be a string or {"type": str, "severity": str}.'
|
|
)
|
|
|
|
if injury_type not in self.VALID_INJURY_TYPES:
|
|
raise serializers.ValidationError(
|
|
f'Invalid injury type: {injury_type}. '
|
|
f'Valid types: {sorted(self.VALID_INJURY_TYPES)}'
|
|
)
|
|
if severity not in self.VALID_SEVERITY_LEVELS:
|
|
raise serializers.ValidationError(
|
|
f'Invalid severity: {severity}. '
|
|
f'Valid levels: {sorted(self.VALID_SEVERITY_LEVELS)}'
|
|
)
|
|
|
|
if injury_type not in seen:
|
|
normalized.append({'type': injury_type, 'severity': severity})
|
|
seen.add(injury_type)
|
|
|
|
return normalized
|
|
|
|
def validate(self, attrs):
|
|
instance = self.instance
|
|
days_per_week = attrs.get('days_per_week', getattr(instance, 'days_per_week', 4))
|
|
preferred_days = attrs.get('preferred_days', getattr(instance, 'preferred_days', []))
|
|
primary_goal = attrs.get('primary_goal', getattr(instance, 'primary_goal', ''))
|
|
secondary_goal = attrs.get('secondary_goal', getattr(instance, 'secondary_goal', ''))
|
|
fitness_level = attrs.get('fitness_level', getattr(instance, 'fitness_level', 2))
|
|
duration = attrs.get(
|
|
'preferred_workout_duration',
|
|
getattr(instance, 'preferred_workout_duration', 45),
|
|
)
|
|
|
|
warnings = []
|
|
|
|
if preferred_days and len(preferred_days) < days_per_week:
|
|
warnings.append(
|
|
f'You selected {days_per_week} days/week but only '
|
|
f'{len(preferred_days)} preferred days. Some days will be auto-assigned.'
|
|
)
|
|
|
|
if primary_goal and secondary_goal and primary_goal == secondary_goal:
|
|
raise serializers.ValidationError({
|
|
'secondary_goal': 'Secondary goal must differ from primary goal.',
|
|
})
|
|
|
|
if primary_goal == 'strength' and duration < 30:
|
|
warnings.append(
|
|
'Strength workouts under 30 minutes may not have enough volume for progress.'
|
|
)
|
|
|
|
if days_per_week > 6:
|
|
warnings.append(
|
|
'Training 7 days/week with no rest days increases injury risk.'
|
|
)
|
|
|
|
# Beginner overtraining risk
|
|
if days_per_week >= 6 and fitness_level <= 1:
|
|
warnings.append(
|
|
'Training 6+ days/week as a beginner significantly increases injury risk. '
|
|
'Consider starting with 3-4 days/week.'
|
|
)
|
|
|
|
# Duration too long for fitness level
|
|
if duration > 90 and fitness_level <= 2:
|
|
warnings.append(
|
|
'Workouts over 90 minutes may be too long for your fitness level. '
|
|
'Consider 45-60 minutes for best results.'
|
|
)
|
|
|
|
# Strength goal without equipment
|
|
equipment = attrs.get('available_equipment', None)
|
|
if equipment is not None and len(equipment) == 0 and primary_goal == 'strength':
|
|
warnings.append(
|
|
'Strength training without equipment limits heavy loading. '
|
|
'Consider adding equipment for better strength gains.'
|
|
)
|
|
|
|
# Hypertrophy with short duration
|
|
if primary_goal == 'hypertrophy' and duration < 30:
|
|
warnings.append(
|
|
'Hypertrophy workouts under 30 minutes may not provide enough volume '
|
|
'for muscle growth. Consider at least 45 minutes.'
|
|
)
|
|
|
|
attrs['_validation_warnings'] = warnings
|
|
return attrs
|
|
|
|
def update(self, instance, validated_data):
|
|
# Pop internal metadata
|
|
validated_data.pop('_validation_warnings', [])
|
|
|
|
# Pop M2M fields so we can set them separately
|
|
equipment = validated_data.pop('available_equipment', None)
|
|
muscles = validated_data.pop('target_muscle_groups', None)
|
|
workout_types = validated_data.pop('preferred_workout_types', None)
|
|
excluded = validated_data.pop('excluded_exercises', None)
|
|
|
|
# Update scalar fields
|
|
for attr, value in validated_data.items():
|
|
setattr(instance, attr, value)
|
|
instance.save()
|
|
|
|
# Update M2M fields only when they are explicitly provided
|
|
if equipment is not None:
|
|
instance.available_equipment.set(equipment)
|
|
if muscles is not None:
|
|
instance.target_muscle_groups.set(muscles)
|
|
if workout_types is not None:
|
|
instance.preferred_workout_types.set(workout_types)
|
|
if excluded is not None:
|
|
instance.excluded_exercises.set(excluded)
|
|
|
|
return instance
|
|
|
|
|
|
# ============================================================
|
|
# GeneratedWorkout Serializers
|
|
# ============================================================
|
|
|
|
class GeneratedWorkoutSerializer(serializers.ModelSerializer):
|
|
"""List-level serializer -- includes workout_type name and basic workout info."""
|
|
workout_type_name = serializers.SerializerMethodField()
|
|
workout_name = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = GeneratedWorkout
|
|
fields = (
|
|
'id',
|
|
'plan',
|
|
'workout',
|
|
'workout_type',
|
|
'workout_type_name',
|
|
'workout_name',
|
|
'scheduled_date',
|
|
'day_of_week',
|
|
'is_rest_day',
|
|
'status',
|
|
'focus_area',
|
|
'target_muscles',
|
|
'user_rating',
|
|
'user_feedback',
|
|
)
|
|
|
|
def get_workout_type_name(self, obj):
|
|
if obj.workout_type:
|
|
return obj.workout_type.display_name or obj.workout_type.name
|
|
return None
|
|
|
|
def get_workout_name(self, obj):
|
|
if obj.workout:
|
|
return obj.workout.name
|
|
return None
|
|
|
|
|
|
class GeneratedWorkoutDetailSerializer(serializers.ModelSerializer):
|
|
"""Full detail serializer -- includes superset breakdown via existing SupersetSerializer."""
|
|
workout_type_name = serializers.SerializerMethodField()
|
|
workout_detail = serializers.SerializerMethodField()
|
|
supersets = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = GeneratedWorkout
|
|
fields = (
|
|
'id',
|
|
'plan',
|
|
'workout',
|
|
'workout_type',
|
|
'workout_type_name',
|
|
'scheduled_date',
|
|
'day_of_week',
|
|
'is_rest_day',
|
|
'status',
|
|
'focus_area',
|
|
'target_muscles',
|
|
'user_rating',
|
|
'user_feedback',
|
|
'workout_detail',
|
|
'supersets',
|
|
)
|
|
|
|
def get_workout_type_name(self, obj):
|
|
if obj.workout_type:
|
|
return obj.workout_type.display_name or obj.workout_type.name
|
|
return None
|
|
|
|
def get_workout_detail(self, obj):
|
|
if obj.workout:
|
|
return WorkoutDetailSerializer(obj.workout).data
|
|
return None
|
|
|
|
def get_supersets(self, obj):
|
|
if obj.workout:
|
|
superset_qs = Superset.objects.filter(workout=obj.workout).order_by('order')
|
|
return SupersetSerializer(superset_qs, many=True).data
|
|
return []
|
|
|
|
|
|
# ============================================================
|
|
# GeneratedWeeklyPlan Serializer
|
|
# ============================================================
|
|
|
|
class GeneratedWeeklyPlanSerializer(serializers.ModelSerializer):
|
|
generated_workouts = GeneratedWorkoutSerializer(many=True, read_only=True)
|
|
|
|
class Meta:
|
|
model = GeneratedWeeklyPlan
|
|
fields = (
|
|
'id',
|
|
'created_at',
|
|
'registered_user',
|
|
'week_start_date',
|
|
'week_end_date',
|
|
'status',
|
|
'preferences_snapshot',
|
|
'generation_time_ms',
|
|
'week_number',
|
|
'is_deload',
|
|
'cycle_id',
|
|
'generated_workouts',
|
|
)
|
|
read_only_fields = ('id', 'created_at', 'registered_user')
|
|
|
|
|
|
# ============================================================
|
|
# MuscleGroupSplit Serializer
|
|
# ============================================================
|
|
|
|
class MuscleGroupSplitSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = MuscleGroupSplit
|
|
fields = '__all__'
|