Additional fixes from parallel hardening streams: - exercise/serializers: remove unused WorkoutEquipment import, add prefetch docs - generator/serializers: N+1 fix in GeneratedWorkoutDetailSerializer (inline workout dict, prefetch-aware supersets) - generator/services/plan_builder: eliminate redundant .save() after .create() via single create_kwargs dict - generator/services/workout_generator: proper type-match validation for HIIT/cardio/core/flexibility; fix diversify type count to account for removed entry - generator/views: request-level caching for get_registered_user helper; prefetch chain for accept_workout - superset/serializers: guard against dangling FK in SupersetExerciseSerializer - workout/helpers: use prefetched data instead of re-querying per superset Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
392 lines
13 KiB
Python
392 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 {
|
|
'id': obj.workout.id,
|
|
'name': obj.workout.name,
|
|
'description': obj.workout.description,
|
|
'estimated_time': obj.workout.estimated_time,
|
|
}
|
|
return None
|
|
|
|
def get_supersets(self, obj):
|
|
if not obj.workout:
|
|
return []
|
|
# Use prefetched data if available (via workout__superset_workout prefetch),
|
|
# otherwise fall back to a query with its own prefetch
|
|
workout = obj.workout
|
|
if hasattr(workout, '_prefetched_objects_cache') and 'superset_workout' in workout._prefetched_objects_cache:
|
|
superset_qs = sorted(workout.superset_workout.all(), key=lambda s: s.order)
|
|
else:
|
|
superset_qs = Superset.objects.filter(
|
|
workout=workout
|
|
).prefetch_related(
|
|
'superset_exercises__exercise',
|
|
).order_by('order')
|
|
return SupersetSerializer(superset_qs, many=True).data
|
|
|
|
|
|
# ============================================================
|
|
# 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__'
|