""" DRF Serializers for Schedule App with Availability Validation """ from rest_framework import serializers from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError as DjangoValidationError from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation from .services import AvailabilityService from smoothschedule.users.models import User class ResourceTypeSerializer(serializers.ModelSerializer): """Serializer for custom resource types""" class Meta: model = ResourceType fields = ['id', 'name', 'description', 'category', 'is_default', 'icon_name', 'created_at', 'updated_at'] read_only_fields = ['id', 'created_at', 'updated_at', 'is_default'] def validate(self, attrs): # If updating, check if trying to modify is_default if self.instance and self.instance.is_default: if 'name' in attrs and attrs['name'] != self.instance.name: # Allow renaming default types pass return attrs def delete(self, instance): """Validate before deletion""" if instance.is_default: raise serializers.ValidationError("Cannot delete default resource types.") if instance.resources.exists(): raise serializers.ValidationError( f"Cannot delete resource type '{instance.name}' because it is in use by {instance.resources.count()} resource(s)." ) class CustomerSerializer(serializers.ModelSerializer): """Serializer for Customer (User with role=CUSTOMER)""" name = serializers.SerializerMethodField() total_spend = serializers.SerializerMethodField() last_visit = serializers.SerializerMethodField() status = serializers.SerializerMethodField() avatar_url = serializers.SerializerMethodField() tags = serializers.SerializerMethodField() user_id = serializers.IntegerField(source='id', read_only=True) city = serializers.SerializerMethodField() state = serializers.SerializerMethodField() zip = serializers.SerializerMethodField() user_data = serializers.SerializerMethodField() class Meta: model = User fields = [ 'id', 'name', 'email', 'phone', 'city', 'state', 'zip', 'total_spend', 'last_visit', 'status', 'avatar_url', 'tags', 'user_id', 'user_data', ] read_only_fields = ['id', 'email'] def get_name(self, obj): return obj.full_name def get_total_spend(self, obj): # TODO: Calculate from payments when implemented return 0 def get_last_visit(self, obj): # TODO: Get from last appointment when implemented return None def get_status(self, obj): return 'Active' if obj.is_active else 'Inactive' def get_avatar_url(self, obj): return None # TODO: Implement avatar def get_tags(self, obj): return [] # TODO: Implement customer tags def get_city(self, obj): return '' # TODO: Add address fields to User model def get_state(self, obj): return '' def get_zip(self, obj): return '' def get_user_data(self, obj): """Return user data needed for masquerading""" return { 'id': obj.id, 'username': obj.username, 'name': obj.full_name, 'email': obj.email, 'role': 'customer', } class StaffSerializer(serializers.ModelSerializer): """Serializer for Staff members (Users with staff roles)""" name = serializers.SerializerMethodField() role = serializers.SerializerMethodField() can_invite_staff = serializers.SerializerMethodField() class Meta: model = User fields = [ 'id', 'username', 'name', 'email', 'phone', 'role', 'is_active', 'permissions', 'can_invite_staff', ] read_only_fields = ['id', 'username', 'email', 'role', 'can_invite_staff'] def get_name(self, obj): return obj.full_name def get_role(self, obj): # Map database roles to frontend roles role_mapping = { 'TENANT_OWNER': 'owner', 'TENANT_MANAGER': 'manager', 'TENANT_STAFF': 'staff', } return role_mapping.get(obj.role, obj.role.lower()) def get_can_invite_staff(self, obj): return obj.can_invite_staff() class ServiceSerializer(serializers.ModelSerializer): """Serializer for Service model""" duration_minutes = serializers.IntegerField(source='duration', read_only=True) class Meta: model = Service fields = [ 'id', 'name', 'description', 'duration', 'duration_minutes', 'price', 'display_order', 'photos', 'is_active', 'created_at', 'updated_at', ] read_only_fields = ['created_at', 'updated_at'] class ResourceSerializer(serializers.ModelSerializer): """Serializer for Resource model""" capacity_description = serializers.SerializerMethodField() user_id = serializers.IntegerField(required=False, allow_null=True) user_name = serializers.CharField(source='user.full_name', read_only=True, allow_null=True) class Meta: model = Resource fields = [ 'id', 'name', 'type', 'user_id', 'user_name', 'description', 'max_concurrent_events', 'buffer_duration', 'is_active', 'capacity_description', 'saved_lane_count', 'created_at', 'updated_at', ] read_only_fields = ['created_at', 'updated_at'] def get_capacity_description(self, obj): if obj.max_concurrent_events == 0: return "Unlimited capacity" elif obj.max_concurrent_events == 1: return "Exclusive use (1 at a time)" return f"Up to {obj.max_concurrent_events} concurrent events" def to_representation(self, instance): """Add user_id to the output""" ret = super().to_representation(instance) ret['user_id'] = instance.user_id return ret def create(self, validated_data): """Handle user_id when creating a resource""" user_id = validated_data.pop('user_id', None) if user_id: try: validated_data['user'] = User.objects.get(id=user_id) except User.DoesNotExist: pass return super().create(validated_data) def update(self, instance, validated_data): """Handle user_id when updating a resource""" user_id = validated_data.pop('user_id', None) if user_id is not None: if user_id: try: validated_data['user'] = User.objects.get(id=user_id) except User.DoesNotExist: pass else: validated_data['user'] = None return super().update(instance, validated_data) class ParticipantSerializer(serializers.ModelSerializer): """Serializer for Participant model""" content_type_str = serializers.SerializerMethodField() participant_display = serializers.SerializerMethodField() class Meta: model = Participant fields = [ 'id', 'event', 'role', 'content_type', 'object_id', 'content_type_str', 'participant_display', 'created_at', ] read_only_fields = ['created_at'] def get_content_type_str(self, obj): return str(obj.content_type) def get_participant_display(self, obj): return str(obj.content_object) if obj.content_object else None class EventSerializer(serializers.ModelSerializer): """ Serializer for Event model with availability validation. CRITICAL: Validates resource availability before saving via AvailabilityService. """ participants = ParticipantSerializer(many=True, read_only=True) duration_minutes = serializers.SerializerMethodField() # Simplified fields for frontend compatibility resource_id = serializers.SerializerMethodField() customer_id = serializers.SerializerMethodField() service_id = serializers.SerializerMethodField() customer_name = serializers.SerializerMethodField() service_name = serializers.SerializerMethodField() is_paid = serializers.SerializerMethodField() # Write-only fields for creating participants resource_ids = serializers.ListField( child=serializers.IntegerField(), write_only=True, required=False, help_text="List of Resource IDs to assign" ) staff_ids = serializers.ListField( child=serializers.IntegerField(), write_only=True, required=False, help_text="List of Staff (User) IDs to assign" ) class Meta: model = Event fields = [ 'id', 'title', 'start_time', 'end_time', 'status', 'notes', 'duration_minutes', 'participants', 'resource_ids', 'staff_ids', 'resource_id', 'customer_id', 'service_id', 'customer_name', 'service_name', 'is_paid', 'created_at', 'updated_at', 'created_by', ] read_only_fields = ['created_at', 'updated_at', 'created_by'] def get_duration_minutes(self, obj): return int(obj.duration.total_seconds() / 60) def get_resource_id(self, obj): """Get first resource ID from participants""" resource_participant = obj.participants.filter(role='RESOURCE').first() return resource_participant.object_id if resource_participant else None def get_customer_id(self, obj): """Get customer ID from participants""" customer_participant = obj.participants.filter(role='CUSTOMER').first() return customer_participant.object_id if customer_participant else None def get_service_id(self, obj): """Get service ID - placeholder for now""" # TODO: Add service link to Event model or participants return 1 def get_customer_name(self, obj): """Get customer name from participant""" customer_participant = obj.participants.filter(role='CUSTOMER').first() if customer_participant and customer_participant.content_object: user = customer_participant.content_object return user.full_name if hasattr(user, 'full_name') else str(user) # Fallback to title return obj.title.split(' - ')[0] if ' - ' in obj.title else obj.title def get_service_name(self, obj): """Get service name from title""" # Extract from title format "Customer Name - Service Name" if ' - ' in obj.title: return obj.title.split(' - ')[-1] return "Service" def get_is_paid(self, obj): """Check if event is paid""" return obj.status == 'PAID' def validate(self, attrs): """ Validate event timing and resource availability. Checks: 1. end_time > start_time 2. start_time not in past (for new events) 3. Resource availability using AvailabilityService """ # For partial updates, get existing values from instance if not provided start_time = attrs.get('start_time') end_time = attrs.get('end_time') resource_ids = attrs.get('resource_ids', []) # If this is a partial update, fill in missing values from existing instance if self.instance: if start_time is None: start_time = self.instance.start_time if end_time is None: end_time = self.instance.end_time # Skip validation if we still don't have both times (shouldn't happen for valid requests) if start_time is None or end_time is None: return attrs # Validation 1: End must be after start if end_time <= start_time: raise serializers.ValidationError({ 'end_time': 'End time must be after start time' }) # Validation 2: No past events (only for new events) if not self.instance: from django.utils import timezone if start_time < timezone.now(): raise serializers.ValidationError({ 'start_time': 'Cannot create events in the past' }) # Validation 3: Check resource availability # CRITICAL: This enforces concurrency limits event_id = self.instance.id if self.instance else None availability_errors = [] for resource_id in resource_ids: try: resource = Resource.objects.get(id=resource_id, is_active=True) except Resource.DoesNotExist: availability_errors.append(f"Resource ID {resource_id} not found or inactive") continue # Call the availability service is_available, reason = AvailabilityService.check_availability( resource=resource, start_time=start_time, end_time=end_time, exclude_event_id=event_id ) if not is_available: availability_errors.append(f"{resource.name}: {reason}") if availability_errors: raise serializers.ValidationError({ 'non_field_errors': availability_errors }) return attrs def create(self, validated_data): """Create event and associated participants""" resource_ids = validated_data.pop('resource_ids', []) staff_ids = validated_data.pop('staff_ids', []) # Set created_by from request user (only if authenticated) request = self.context.get('request') if request and hasattr(request, 'user') and request.user.is_authenticated: validated_data['created_by'] = request.user else: validated_data['created_by'] = None # TODO: Remove for production # Create the event event = Event.objects.create(**validated_data) # Create Resource participants resource_content_type = ContentType.objects.get_for_model(Resource) for resource_id in resource_ids: Participant.objects.create( event=event, content_type=resource_content_type, object_id=resource_id, role=Participant.Role.RESOURCE ) # Create Staff participants from smoothschedule.users.models import User user_content_type = ContentType.objects.get_for_model(User) for staff_id in staff_ids: Participant.objects.create( event=event, content_type=user_content_type, object_id=staff_id, role=Participant.Role.STAFF ) return event def update(self, instance, validated_data): """Update event. Participants managed separately.""" validated_data.pop('resource_ids', None) validated_data.pop('staff_ids', None) for attr, value in validated_data.items(): setattr(instance, attr, value) instance.save() return instance class ScheduledTaskSerializer(serializers.ModelSerializer): """Serializer for ScheduledTask model""" created_by_name = serializers.SerializerMethodField() plugin_display_name = serializers.SerializerMethodField() class Meta: model = ScheduledTask fields = [ 'id', 'name', 'description', 'plugin_name', 'plugin_display_name', 'plugin_config', 'schedule_type', 'cron_expression', 'interval_minutes', 'run_at', 'status', 'last_run_at', 'last_run_status', 'last_run_result', 'next_run_at', 'created_at', 'updated_at', 'created_by', 'created_by_name', 'celery_task_id', ] read_only_fields = [ 'id', 'created_at', 'updated_at', 'last_run_at', 'last_run_status', 'last_run_result', 'next_run_at', 'created_by', 'celery_task_id', ] def get_created_by_name(self, obj): """Get name of user who created the task""" if obj.created_by: return obj.created_by.get_full_name() or obj.created_by.username return None def get_plugin_display_name(self, obj): """Get display name of the plugin""" from .plugins import registry plugin_class = registry.get(obj.plugin_name) if plugin_class: return plugin_class.display_name return obj.plugin_name def validate(self, attrs): """Validate schedule configuration""" schedule_type = attrs.get('schedule_type') if schedule_type == ScheduledTask.ScheduleType.CRON: if not attrs.get('cron_expression'): raise serializers.ValidationError({ 'cron_expression': 'Cron expression is required for CRON schedule type' }) if schedule_type == ScheduledTask.ScheduleType.INTERVAL: if not attrs.get('interval_minutes'): raise serializers.ValidationError({ 'interval_minutes': 'Interval minutes is required for INTERVAL schedule type' }) if schedule_type == ScheduledTask.ScheduleType.ONE_TIME: if not attrs.get('run_at'): raise serializers.ValidationError({ 'run_at': 'Run at datetime is required for ONE_TIME schedule type' }) return attrs def validate_plugin_name(self, value): """Validate that the plugin exists""" from .plugins import registry if not registry.get(value): raise serializers.ValidationError(f"Plugin '{value}' not found") return value def validate_plugin_config(self, value): """Validate plugin configuration against schema""" if not isinstance(value, dict): raise serializers.ValidationError("Plugin config must be a dictionary") return value def create(self, validated_data): """Create scheduled task and calculate next run time""" task = super().create(validated_data) task.update_next_run_time() return task def update(self, instance, validated_data): """Update scheduled task and recalculate next run time""" task = super().update(instance, validated_data) task.update_next_run_time() return task class TaskExecutionLogSerializer(serializers.ModelSerializer): """Serializer for TaskExecutionLog model""" scheduled_task_name = serializers.CharField(source='scheduled_task.name', read_only=True) plugin_name = serializers.CharField(source='scheduled_task.plugin_name', read_only=True) class Meta: model = TaskExecutionLog fields = [ 'id', 'scheduled_task', 'scheduled_task_name', 'plugin_name', 'started_at', 'completed_at', 'status', 'result', 'error_message', 'execution_time_ms', ] read_only_fields = '__all__' class PluginInfoSerializer(serializers.Serializer): """Serializer for plugin metadata""" name = serializers.CharField() display_name = serializers.CharField() description = serializers.CharField() category = serializers.CharField() config_schema = serializers.DictField() class PluginTemplateSerializer(serializers.ModelSerializer): """Serializer for PluginTemplate model""" author_name = serializers.CharField(read_only=True) approved_by_name = serializers.SerializerMethodField() can_publish = serializers.SerializerMethodField() validation_errors = serializers.SerializerMethodField() class Meta: model = PluginTemplate fields = [ 'id', 'name', 'slug', 'description', 'short_description', 'plugin_code', 'plugin_code_hash', 'template_variables', 'default_config', 'visibility', 'category', 'tags', 'author', 'author_name', 'version', 'license_type', 'logo_url', 'is_approved', 'approved_by', 'approved_by_name', 'approved_at', 'rejection_reason', 'install_count', 'rating_average', 'rating_count', 'created_at', 'updated_at', 'published_at', 'can_publish', 'validation_errors', ] read_only_fields = [ 'id', 'slug', 'plugin_code_hash', 'template_variables', 'author', 'author_name', 'is_approved', 'approved_by', 'approved_by_name', 'approved_at', 'rejection_reason', 'install_count', 'rating_average', 'rating_count', 'created_at', 'updated_at', 'published_at', ] def get_approved_by_name(self, obj): """Get name of user who approved the plugin""" if obj.approved_by: return obj.approved_by.get_full_name() or obj.approved_by.username return None def get_can_publish(self, obj): """Check if plugin can be published to marketplace""" return obj.can_be_published() def get_validation_errors(self, obj): """Get validation errors for publishing""" from .safe_scripting import validate_plugin_whitelist validation = validate_plugin_whitelist(obj.plugin_code) if not validation['valid']: return validation['errors'] return [] def create(self, validated_data): """Set author from request user""" request = self.context.get('request') if request and hasattr(request, 'user'): validated_data['author'] = request.user return super().create(validated_data) def validate_plugin_code(self, value): """Validate plugin code and extract template variables""" if not value or not value.strip(): raise serializers.ValidationError("Plugin code cannot be empty") # Extract template variables from .template_parser import TemplateVariableParser try: template_vars = TemplateVariableParser.extract_variables(value) except Exception as e: raise serializers.ValidationError(f"Failed to parse template variables: {str(e)}") return value class PluginTemplateListSerializer(serializers.ModelSerializer): """Lightweight serializer for plugin template listing""" author_name = serializers.CharField(read_only=True) class Meta: model = PluginTemplate fields = [ 'id', 'name', 'slug', 'short_description', 'description', 'visibility', 'category', 'tags', 'author_name', 'version', 'license_type', 'logo_url', 'is_approved', 'install_count', 'rating_average', 'rating_count', 'created_at', 'updated_at', 'published_at', ] read_only_fields = fields # All fields are read-only for list view class PluginInstallationSerializer(serializers.ModelSerializer): """Serializer for PluginInstallation model""" template_name = serializers.CharField(source='template.name', read_only=True) template_slug = serializers.CharField(source='template.slug', read_only=True) template_description = serializers.CharField(source='template.description', read_only=True) category = serializers.CharField(source='template.category', read_only=True) version = serializers.CharField(source='template.version', read_only=True) author_name = serializers.CharField(source='template.author_name', read_only=True) logo_url = serializers.CharField(source='template.logo_url', read_only=True) template_variables = serializers.JSONField(source='template.template_variables', read_only=True) scheduled_task_name = serializers.CharField(source='scheduled_task.name', read_only=True) installed_by_name = serializers.SerializerMethodField() has_update = serializers.SerializerMethodField() class Meta: model = PluginInstallation fields = [ 'id', 'template', 'template_name', 'template_slug', 'template_description', 'category', 'version', 'author_name', 'logo_url', 'template_variables', 'scheduled_task', 'scheduled_task_name', 'installed_by', 'installed_by_name', 'installed_at', 'config_values', 'template_version_hash', 'rating', 'review', 'reviewed_at', 'has_update', ] read_only_fields = [ 'id', 'installed_by', 'installed_by_name', 'installed_at', 'template_version_hash', 'reviewed_at', ] def get_installed_by_name(self, obj): """Get name of user who installed the plugin""" if obj.installed_by: return obj.installed_by.get_full_name() or obj.installed_by.username return None def get_has_update(self, obj): """Check if template has been updated""" return obj.has_update_available() def create(self, validated_data): """ Create plugin installation. Installation makes the plugin available in "My Plugins". Scheduling is optional and done separately. """ request = self.context.get('request') template = validated_data.get('template') # Set installed_by from request user if request and hasattr(request, 'user') and request.user.is_authenticated: validated_data['installed_by'] = request.user # Store template version hash for update detection if template: import hashlib validated_data['template_version_hash'] = hashlib.sha256( template.plugin_code.encode('utf-8') ).hexdigest() # Don't require scheduled_task on creation # It can be added later when user schedules the plugin validated_data.pop('scheduled_task', None) return super().create(validated_data)