- 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>
1008 lines
37 KiB
Python
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) |