Files
WerkoutAPI/generator/serializers.py
Trey t 1c61b80731 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>
2026-02-22 20:07:40 -06:00

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__'