Files
smoothschedule/smoothschedule/schedule/serializers.py
poduck dbe91ec2ff feat(auth): Convert login system to use email as username
- Backend login now accepts 'email' field (with backward compatibility)
- User creation (signup, invitation, customer) uses email as username
- Frontend login form updated with email input and validation
- Updated test users to use email addresses as usernames
- Updated all translation files (en, es, fr, de)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:38:53 -05:00

1008 lines
37 KiB
Python

"""
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, EventPlugin, GlobalEventPlugin, EmailTemplate
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()
first_name = serializers.CharField(write_only=True, required=False, allow_blank=True)
last_name = serializers.CharField(write_only=True, required=False, allow_blank=True)
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', 'first_name', 'last_name', 'email', 'phone', 'city', 'state', 'zip',
'total_spend', 'last_visit', 'status', 'avatar_url', 'tags',
'user_id', 'user_data',
]
read_only_fields = ['id']
def create(self, validated_data):
"""Create a customer with email as username"""
import uuid
email = validated_data.get('email', '')
# Use email as username, or generate a UUID-based username if no email
if email:
validated_data['username'] = email.lower()
else:
validated_data['username'] = f"customer_{uuid.uuid4().hex[:8]}"
return super().create(validated_data)
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',
'is_archived_by_quota',
]
read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota']
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',
'is_archived_by_quota',
]
read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota']
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.
Status mapping (frontend -> backend):
- PENDING -> SCHEDULED
- CONFIRMED -> SCHEDULED
- CANCELLED -> CANCELED
- NO_SHOW -> NOSHOW
"""
# Status mapping: frontend value -> backend value
STATUS_MAPPING = {
'PENDING': 'SCHEDULED',
'CONFIRMED': 'SCHEDULED',
'CANCELLED': 'CANCELED',
'NO_SHOW': 'NOSHOW',
}
# Reverse mapping for serialization: backend value -> frontend value
STATUS_REVERSE_MAPPING = {
'SCHEDULED': 'CONFIRMED',
'CANCELED': 'CANCELLED',
'NOSHOW': 'NO_SHOW',
}
participants = ParticipantSerializer(many=True, read_only=True)
duration_minutes = serializers.SerializerMethodField()
# Override status field to allow frontend values
status = serializers.CharField(required=False)
# 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_status(self, value):
"""Map frontend status values to backend values"""
if value in self.STATUS_MAPPING:
return self.STATUS_MAPPING[value]
# Accept backend values directly
valid_backend_statuses = [s.value for s in Event.Status]
if value in valid_backend_statuses:
return value
raise serializers.ValidationError(
f'Invalid status "{value}". Valid values are: '
f'{", ".join(list(self.STATUS_MAPPING.keys()) + valid_backend_statuses)}'
)
def to_representation(self, instance):
"""Map backend status values to frontend values when serializing"""
data = super().to_representation(instance)
if 'status' in data and data['status'] in self.STATUS_REVERSE_MAPPING:
data['status'] = self.STATUS_REVERSE_MAPPING[data['status']]
return data
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)
class EventPluginSerializer(serializers.ModelSerializer):
"""
Serializer for EventPlugin - attaching plugins to calendar events.
Provides a visual-friendly representation of when plugins run:
- trigger: 'before_start', 'at_start', 'after_start', 'after_end', 'on_complete', 'on_cancel'
- offset_minutes: 0, 5, 10, 15, 30, 60 (for time-based triggers)
"""
plugin_name = serializers.CharField(source='plugin_installation.template.name', read_only=True)
plugin_description = serializers.CharField(source='plugin_installation.template.short_description', read_only=True)
plugin_category = serializers.CharField(source='plugin_installation.template.category', read_only=True)
plugin_logo_url = serializers.CharField(source='plugin_installation.template.logo_url', read_only=True)
trigger_display = serializers.CharField(source='get_trigger_display', read_only=True)
execution_time = serializers.SerializerMethodField()
timing_description = serializers.SerializerMethodField()
class Meta:
model = EventPlugin
fields = [
'id',
'event',
'plugin_installation',
'plugin_name',
'plugin_description',
'plugin_category',
'plugin_logo_url',
'trigger',
'trigger_display',
'offset_minutes',
'timing_description',
'execution_time',
'is_active',
'execution_order',
'created_at',
]
read_only_fields = ['id', 'created_at']
def get_execution_time(self, obj):
"""Get the calculated execution time"""
exec_time = obj.get_execution_time()
return exec_time.isoformat() if exec_time else None
def get_timing_description(self, obj):
"""
Generate a human-readable description of when the plugin runs.
Examples: "At start", "10 minutes before start", "30 minutes after end"
"""
trigger = obj.trigger
offset = obj.offset_minutes
if trigger == EventPlugin.Trigger.BEFORE_START:
if offset == 0:
return "At start"
return f"{offset} min before start"
elif trigger == EventPlugin.Trigger.AT_START:
if offset == 0:
return "At start"
return f"{offset} min after start"
elif trigger == EventPlugin.Trigger.AFTER_START:
if offset == 0:
return "At start"
return f"{offset} min after start"
elif trigger == EventPlugin.Trigger.AFTER_END:
if offset == 0:
return "At end"
return f"{offset} min after end"
elif trigger == EventPlugin.Trigger.ON_COMPLETE:
return "When completed"
elif trigger == EventPlugin.Trigger.ON_CANCEL:
return "When canceled"
return "Unknown"
def validate(self, attrs):
"""Validate that offset makes sense for the trigger type"""
trigger = attrs.get('trigger', EventPlugin.Trigger.AT_START)
offset = attrs.get('offset_minutes', 0)
# Event-driven triggers don't use offset
if trigger in [EventPlugin.Trigger.ON_COMPLETE, EventPlugin.Trigger.ON_CANCEL]:
if offset != 0:
attrs['offset_minutes'] = 0 # Auto-correct instead of error
return attrs
class GlobalEventPluginSerializer(serializers.ModelSerializer):
"""
Serializer for GlobalEventPlugin - rules for auto-attaching plugins to ALL events.
When created, automatically applies to:
1. All existing events
2. All future events as they are created
"""
plugin_name = serializers.CharField(source='plugin_installation.template.name', read_only=True)
plugin_description = serializers.CharField(source='plugin_installation.template.short_description', read_only=True)
plugin_category = serializers.CharField(source='plugin_installation.template.category', read_only=True)
plugin_logo_url = serializers.CharField(source='plugin_installation.template.logo_url', read_only=True)
trigger_display = serializers.CharField(source='get_trigger_display', read_only=True)
timing_description = serializers.SerializerMethodField()
events_count = serializers.SerializerMethodField()
class Meta:
model = GlobalEventPlugin
fields = [
'id',
'plugin_installation',
'plugin_name',
'plugin_description',
'plugin_category',
'plugin_logo_url',
'trigger',
'trigger_display',
'offset_minutes',
'timing_description',
'is_active',
'apply_to_existing',
'execution_order',
'events_count',
'created_at',
'updated_at',
'created_by',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'created_by']
def get_timing_description(self, obj):
"""Generate a human-readable description of when the plugin runs."""
trigger = obj.trigger
offset = obj.offset_minutes
if trigger == 'before_start':
if offset == 0:
return "At start"
return f"{offset} min before start"
elif trigger == 'at_start':
if offset == 0:
return "At start"
return f"{offset} min after start"
elif trigger == 'after_start':
if offset == 0:
return "At start"
return f"{offset} min after start"
elif trigger == 'after_end':
if offset == 0:
return "At end"
return f"{offset} min after end"
elif trigger == 'on_complete':
return "When completed"
elif trigger == 'on_cancel':
return "When canceled"
return "Unknown"
def get_events_count(self, obj):
"""Get the count of events this rule applies to."""
return EventPlugin.objects.filter(
plugin_installation=obj.plugin_installation,
trigger=obj.trigger,
offset_minutes=obj.offset_minutes,
).count()
def validate(self, attrs):
"""Validate the global event plugin configuration."""
trigger = attrs.get('trigger', 'at_start')
offset = attrs.get('offset_minutes', 0)
# Event-driven triggers don't use offset
if trigger in ['on_complete', 'on_cancel']:
if offset != 0:
attrs['offset_minutes'] = 0
return attrs
def create(self, validated_data):
"""Create the global rule and apply to existing events."""
# Set the created_by from request context
request = self.context.get('request')
if request and hasattr(request, 'user'):
validated_data['created_by'] = request.user
return super().create(validated_data)
class EmailTemplateSerializer(serializers.ModelSerializer):
"""Full serializer for EmailTemplate CRUD operations"""
created_by_name = serializers.SerializerMethodField()
class Meta:
model = EmailTemplate
fields = [
'id', 'name', 'description', 'subject',
'html_content', 'text_content', 'scope',
'is_default', 'category', 'preview_context',
'created_by', 'created_by_name',
'created_at', 'updated_at',
]
read_only_fields = ['created_at', 'updated_at', 'created_by', 'created_by_name']
def get_created_by_name(self, obj):
"""Get the name of the user who created the template"""
if obj.created_by:
return obj.created_by.full_name or obj.created_by.username
return None
def validate(self, attrs):
"""Validate template content"""
html = attrs.get('html_content', '')
text = attrs.get('text_content', '')
# At least one content type is required
if not html and not text:
raise serializers.ValidationError(
"At least HTML or text content is required"
)
return attrs
def create(self, validated_data):
"""Set created_by from request context"""
request = self.context.get('request')
if request and hasattr(request, 'user') and request.user.is_authenticated:
validated_data['created_by'] = request.user
return super().create(validated_data)
class EmailTemplateListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for email template dropdowns and listings"""
class Meta:
model = EmailTemplate
fields = ['id', 'name', 'description', 'category', 'scope', 'updated_at']
read_only_fields = fields
class EmailTemplatePreviewSerializer(serializers.Serializer):
"""Serializer for email template preview endpoint"""
subject = serializers.CharField()
html_content = serializers.CharField(allow_blank=True, required=False, default='')
text_content = serializers.CharField(allow_blank=True, required=False, default='')
context = serializers.DictField(required=False, default=dict)