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 ).prefetch_related( 'supersetexercise_set__exercise', ).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__'