Files
smoothschedule/smoothschedule/platform_admin/serializers.py
poduck 08b51d1a5f feat: Quota overage system, updated tier pricing, and communication addons
Quota Overage System:
- Add QuotaOverage model for tracking resource/user quota overages
- Implement 30-day grace period with email notifications (immediate, 7-day, 1-day)
- Add QuotaWarningBanner component in BusinessLayout
- Add QuotaSettings page for managing overages and archiving resources
- Add Celery tasks for automated quota checks and expiration handling
- Add quota management API endpoints

Updated Tier Pricing (Stripe: 2.9% + $0.30):
- Free: No payments (requires addon)
- Starter: 4% + $0.40
- Professional: 3.5% + $0.35
- Business: 3.25% + $0.32
- Enterprise: 3% + $0.30

New Subscription Addons:
- Online Payments ($5/mo + 5% + $0.50) - for Free tier
- SMS Notifications ($10/mo) - enables SMS reminders
- Masked Calling ($15/mo) - enables anonymous calling

BusinessEditModal Improvements:
- Increased width to match PlanModal (max-w-3xl)
- Added all tier options with auto-update on tier change
- Added limits configuration and permissions sections

Backend Fixes:
- Fixed SubscriptionPlan serializer to include all communication fields
- Allow blank business_tier for addon plans
- Added migration for business_tier field changes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 13:05:02 -05:00

859 lines
35 KiB
Python

"""
Platform Serializers
Serializers for platform-level operations (viewing tenants, users, metrics)
"""
from rest_framework import serializers
from core.models import Tenant, Domain
from smoothschedule.users.models import User
from .models import TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress
class PlatformSettingsSerializer(serializers.Serializer):
"""Serializer for platform settings (read-only, with masked keys)"""
stripe_secret_key_masked = serializers.SerializerMethodField()
stripe_publishable_key_masked = serializers.SerializerMethodField()
stripe_webhook_secret_masked = serializers.SerializerMethodField()
stripe_account_id = serializers.CharField(read_only=True)
stripe_account_name = serializers.CharField(read_only=True)
stripe_keys_validated_at = serializers.DateTimeField(read_only=True)
stripe_validation_error = serializers.CharField(read_only=True)
has_stripe_keys = serializers.SerializerMethodField()
stripe_keys_from_env = serializers.SerializerMethodField()
email_check_interval_minutes = serializers.IntegerField(read_only=True)
updated_at = serializers.DateTimeField(read_only=True)
def get_stripe_secret_key_masked(self, obj):
return obj.mask_key(obj.get_stripe_secret_key())
def get_stripe_publishable_key_masked(self, obj):
return obj.mask_key(obj.get_stripe_publishable_key())
def get_stripe_webhook_secret_masked(self, obj):
return obj.mask_key(obj.get_stripe_webhook_secret())
def get_has_stripe_keys(self, obj):
return obj.has_stripe_keys()
def get_stripe_keys_from_env(self, obj):
return obj.stripe_keys_from_env()
class StripeKeysUpdateSerializer(serializers.Serializer):
"""Serializer for updating Stripe keys"""
stripe_secret_key = serializers.CharField(required=False, allow_blank=True)
stripe_publishable_key = serializers.CharField(required=False, allow_blank=True)
stripe_webhook_secret = serializers.CharField(required=False, allow_blank=True)
class OAuthSettingsSerializer(serializers.Serializer):
"""Serializer for OAuth settings"""
oauth_allow_registration = serializers.BooleanField(required=False, default=True)
# Google
oauth_google_enabled = serializers.BooleanField(required=False, default=False)
oauth_google_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_google_client_secret = serializers.CharField(required=False, allow_blank=True)
# Apple
oauth_apple_enabled = serializers.BooleanField(required=False, default=False)
oauth_apple_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_apple_client_secret = serializers.CharField(required=False, allow_blank=True)
oauth_apple_team_id = serializers.CharField(required=False, allow_blank=True)
oauth_apple_key_id = serializers.CharField(required=False, allow_blank=True)
# Facebook
oauth_facebook_enabled = serializers.BooleanField(required=False, default=False)
oauth_facebook_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_facebook_client_secret = serializers.CharField(required=False, allow_blank=True)
# LinkedIn
oauth_linkedin_enabled = serializers.BooleanField(required=False, default=False)
oauth_linkedin_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_linkedin_client_secret = serializers.CharField(required=False, allow_blank=True)
# Microsoft
oauth_microsoft_enabled = serializers.BooleanField(required=False, default=False)
oauth_microsoft_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_microsoft_client_secret = serializers.CharField(required=False, allow_blank=True)
oauth_microsoft_tenant_id = serializers.CharField(required=False, allow_blank=True)
# Twitter
oauth_twitter_enabled = serializers.BooleanField(required=False, default=False)
oauth_twitter_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_twitter_client_secret = serializers.CharField(required=False, allow_blank=True)
# Twitch
oauth_twitch_enabled = serializers.BooleanField(required=False, default=False)
oauth_twitch_client_id = serializers.CharField(required=False, allow_blank=True)
oauth_twitch_client_secret = serializers.CharField(required=False, allow_blank=True)
class OAuthSettingsResponseSerializer(serializers.Serializer):
"""Response serializer for OAuth settings (with masked secrets)"""
oauth_allow_registration = serializers.BooleanField()
google = serializers.DictField()
apple = serializers.DictField()
facebook = serializers.DictField()
linkedin = serializers.DictField()
microsoft = serializers.DictField()
twitter = serializers.DictField()
twitch = serializers.DictField()
class SubscriptionPlanSerializer(serializers.ModelSerializer):
"""Serializer for subscription plans"""
class Meta:
model = SubscriptionPlan
fields = [
'id', 'name', 'description', 'plan_type',
'stripe_product_id', 'stripe_price_id',
'price_monthly', 'price_yearly', 'business_tier',
'features', 'limits', 'permissions',
'transaction_fee_percent', 'transaction_fee_fixed',
# SMS & Communication Settings
'sms_enabled', 'sms_price_per_message_cents',
# Masked Calling Settings
'masked_calling_enabled', 'masked_calling_price_per_minute_cents',
# Proxy Number Settings
'proxy_number_enabled', 'proxy_number_monthly_fee_cents',
# Default Credit Settings
'default_auto_reload_enabled', 'default_auto_reload_threshold_cents',
'default_auto_reload_amount_cents',
# Status flags
'is_active', 'is_public', 'is_most_popular', 'show_price',
'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
class SubscriptionPlanCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating subscription plans with optional Stripe integration"""
create_stripe_product = serializers.BooleanField(default=False, write_only=True)
class Meta:
model = SubscriptionPlan
fields = [
'name', 'description', 'plan_type',
'stripe_product_id', 'stripe_price_id',
'price_monthly', 'price_yearly', 'business_tier',
'features', 'limits', 'permissions',
'transaction_fee_percent', 'transaction_fee_fixed',
# SMS & Communication Settings
'sms_enabled', 'sms_price_per_message_cents',
# Masked Calling Settings
'masked_calling_enabled', 'masked_calling_price_per_minute_cents',
# Proxy Number Settings
'proxy_number_enabled', 'proxy_number_monthly_fee_cents',
# Default Credit Settings
'default_auto_reload_enabled', 'default_auto_reload_threshold_cents',
'default_auto_reload_amount_cents',
# Status flags
'is_active', 'is_public', 'is_most_popular', 'show_price',
'create_stripe_product'
]
def create(self, validated_data):
create_stripe = validated_data.pop('create_stripe_product', False)
if create_stripe and validated_data.get('price_monthly'):
# Create Stripe product and price
import stripe
from django.conf import settings
stripe.api_key = settings.STRIPE_SECRET_KEY
try:
# Create product
product = stripe.Product.create(
name=validated_data['name'],
description=validated_data.get('description', ''),
metadata={'plan_type': validated_data.get('plan_type', 'base')}
)
validated_data['stripe_product_id'] = product.id
# Create price
price = stripe.Price.create(
product=product.id,
unit_amount=int(validated_data['price_monthly'] * 100), # Convert to cents
currency='usd',
recurring={'interval': 'month'}
)
validated_data['stripe_price_id'] = price.id
except stripe.error.StripeError as e:
raise serializers.ValidationError({'stripe': str(e)})
return super().create(validated_data)
class TenantSerializer(serializers.ModelSerializer):
"""Serializer for Tenant (Business) listing"""
subdomain = serializers.SerializerMethodField()
tier = serializers.CharField(source='subscription_tier')
user_count = serializers.SerializerMethodField()
owner = serializers.SerializerMethodField()
class Meta:
model = Tenant
fields = [
'id', 'name', 'subdomain', 'tier', 'is_active',
'created_on', 'user_count', 'owner', 'max_users',
'max_resources', 'contact_email', 'phone',
# Platform permissions
'can_manage_oauth_credentials',
]
read_only_fields = fields
def get_subdomain(self, obj):
"""Get primary subdomain for this tenant"""
primary_domain = obj.domains.filter(is_primary=True, is_custom_domain=False).first()
if primary_domain:
# Extract subdomain from domain (e.g., 'business1.lvh.me' -> 'business1')
return primary_domain.domain.split('.')[0]
return obj.schema_name
def get_user_count(self, obj):
"""Get count of users in this tenant's schema"""
# This requires querying the tenant schema
# For now, return 0 - we can optimize this with annotations
return 0
def get_owner(self, obj):
"""Get the tenant owner user"""
# Query public schema for users with this tenant as their business
try:
owner = User.objects.filter(
role=User.Role.TENANT_OWNER,
tenant=obj
).first()
if owner:
return {
'id': owner.id,
'username': owner.username,
'full_name': owner.full_name,
'email': owner.email,
'role': owner.role.lower(),
'email_verified': owner.email_verified,
}
except:
pass
return None
class TenantUpdateSerializer(serializers.ModelSerializer):
"""Serializer for updating Tenant permissions (platform admins only)"""
class Meta:
model = Tenant
fields = [
'id', 'name', 'is_active', 'subscription_tier',
'max_users', 'max_resources',
# Platform permissions
'can_manage_oauth_credentials',
'can_accept_payments',
'can_use_custom_domain',
'can_white_label',
'can_api_access',
]
read_only_fields = ['id']
def update(self, instance, validated_data):
"""Update tenant with validated data"""
from django_tenants.utils import schema_context
for attr, value in validated_data.items():
setattr(instance, attr, value)
# Must save in public schema
with schema_context('public'):
instance.save()
return instance
class TenantCreateSerializer(serializers.Serializer):
"""Serializer for creating a new Tenant with domain"""
# Required fields
name = serializers.CharField(max_length=100)
subdomain = serializers.CharField(max_length=63) # Max subdomain length
# Optional fields with defaults
subscription_tier = serializers.ChoiceField(
choices=['FREE', 'STARTER', 'PROFESSIONAL', 'ENTERPRISE'],
default='FREE'
)
is_active = serializers.BooleanField(default=True)
max_users = serializers.IntegerField(default=5, min_value=1)
max_resources = serializers.IntegerField(default=10, min_value=1)
contact_email = serializers.EmailField(required=False, allow_blank=True)
phone = serializers.CharField(max_length=20, required=False, allow_blank=True)
# Platform permissions
can_manage_oauth_credentials = serializers.BooleanField(default=False)
# Owner details (optional - create owner user if provided)
owner_email = serializers.EmailField(required=False)
owner_name = serializers.CharField(max_length=150, required=False)
owner_password = serializers.CharField(max_length=128, required=False, write_only=True)
def validate_subdomain(self, value):
"""Validate subdomain is unique and valid"""
import re
# Check format (lowercase alphanumeric and hyphens, must start with letter)
if not re.match(r'^[a-z][a-z0-9-]*$', value.lower()):
raise serializers.ValidationError(
"Subdomain must start with a letter and contain only lowercase letters, numbers, and hyphens"
)
# Check if subdomain already exists as schema_name
if Tenant.objects.filter(schema_name=value.lower()).exists():
raise serializers.ValidationError("This subdomain is already taken")
# Check if domain already exists
domain_name = f"{value.lower()}.lvh.me" # TODO: Make base domain configurable
if Domain.objects.filter(domain=domain_name).exists():
raise serializers.ValidationError("This subdomain is already taken")
# Reserved subdomains
reserved = ['www', 'api', 'admin', 'platform', 'app', 'mail', 'smtp', 'ftp', 'public']
if value.lower() in reserved:
raise serializers.ValidationError("This subdomain is reserved")
return value.lower()
def validate(self, attrs):
"""Cross-field validation"""
# If owner email is provided, name and password should also be provided
owner_email = attrs.get('owner_email')
owner_name = attrs.get('owner_name')
owner_password = attrs.get('owner_password')
if owner_email:
if not owner_name:
raise serializers.ValidationError({
'owner_name': 'Owner name is required when creating an owner'
})
if not owner_password:
raise serializers.ValidationError({
'owner_password': 'Owner password is required when creating an owner'
})
# Check if email already exists
if User.objects.filter(email=owner_email).exists():
raise serializers.ValidationError({
'owner_email': 'A user with this email already exists'
})
return attrs
def create(self, validated_data):
"""Create tenant, domain, and optionally owner user"""
from django.db import transaction
from django_tenants.utils import schema_context
subdomain = validated_data.pop('subdomain')
owner_email = validated_data.pop('owner_email', None)
owner_name = validated_data.pop('owner_name', None)
owner_password = validated_data.pop('owner_password', None)
# Must create tenant in public schema
with schema_context('public'):
with transaction.atomic():
# Create tenant
tenant = Tenant.objects.create(
schema_name=subdomain,
name=validated_data.get('name'),
subscription_tier=validated_data.get('subscription_tier', 'FREE'),
is_active=validated_data.get('is_active', True),
max_users=validated_data.get('max_users', 5),
max_resources=validated_data.get('max_resources', 10),
contact_email=validated_data.get('contact_email', ''),
phone=validated_data.get('phone', ''),
can_manage_oauth_credentials=validated_data.get('can_manage_oauth_credentials', False),
)
# Create primary domain
domain_name = f"{subdomain}.lvh.me" # TODO: Make base domain configurable
Domain.objects.create(
domain=domain_name,
tenant=tenant,
is_primary=True,
is_custom_domain=False,
)
# Create owner user if details provided
if owner_email and owner_name and owner_password:
# Split name into first/last
name_parts = owner_name.split(' ', 1)
first_name = name_parts[0]
last_name = name_parts[1] if len(name_parts) > 1 else ''
owner = User.objects.create_user(
username=owner_email, # Use email as username
email=owner_email,
password=owner_password,
first_name=first_name,
last_name=last_name,
role=User.Role.TENANT_OWNER,
tenant=tenant,
)
return tenant
class PlatformUserSerializer(serializers.ModelSerializer):
"""Serializer for User listing (platform view)"""
business = serializers.SerializerMethodField()
business_name = serializers.SerializerMethodField()
business_subdomain = serializers.SerializerMethodField()
full_name = serializers.SerializerMethodField()
role = serializers.SerializerMethodField()
class Meta:
model = User
fields = [
'id', 'email', 'username', 'first_name', 'last_name', 'full_name', 'role',
'is_active', 'is_staff', 'is_superuser', 'email_verified', 'permissions',
'business', 'business_name', 'business_subdomain',
'date_joined', 'last_login', 'created_at'
]
read_only_fields = fields
def get_role(self, obj):
return obj.role.lower()
def get_full_name(self, obj):
"""Get user's full name"""
return obj.full_name
def get_business(self, obj):
"""Get tenant ID if user belongs to a tenant"""
if obj.tenant:
return obj.tenant.id
return None
def get_business_name(self, obj):
"""Get tenant name if user belongs to a tenant"""
if obj.tenant:
return obj.tenant.name
return None
def get_business_subdomain(self, obj):
"""Get tenant subdomain if user belongs to a tenant"""
if obj.tenant:
primary_domain = obj.tenant.domains.filter(is_primary=True, is_custom_domain=False).first()
if primary_domain:
return primary_domain.domain.split('.')[0]
return obj.tenant.schema_name
return None
class PlatformMetricsSerializer(serializers.Serializer):
"""Serializer for platform dashboard metrics"""
total_tenants = serializers.IntegerField()
active_tenants = serializers.IntegerField()
total_users = serializers.IntegerField()
mrr = serializers.DecimalField(max_digits=10, decimal_places=2)
growth_rate = serializers.FloatField()
class TenantInvitationSerializer(serializers.ModelSerializer):
"""Serializer for TenantInvitation model"""
invited_by_email = serializers.ReadOnlyField(source='invited_by.email')
created_tenant_name = serializers.ReadOnlyField(source='created_tenant.name')
created_user_email = serializers.ReadOnlyField(source='created_user.email')
class Meta:
model = TenantInvitation
fields = [
'id', 'email', 'token', 'status', 'suggested_business_name',
'subscription_tier', 'custom_max_users', 'custom_max_resources',
'permissions', 'limits', 'personal_message', 'invited_by',
'invited_by_email', 'created_at', 'expires_at', 'accepted_at',
'created_tenant', 'created_tenant_name', 'created_user', 'created_user_email',
]
read_only_fields = ['id', 'token', 'status', 'created_at', 'expires_at', 'accepted_at',
'created_tenant', 'created_tenant_name', 'created_user', 'created_user_email',
'invited_by_email']
extra_kwargs = {
'invited_by': {'write_only': True}, # Only send on creation
}
def validate_permissions(self, value):
"""Validate that permissions is a dictionary with boolean values"""
if not isinstance(value, dict):
raise serializers.ValidationError("Permissions must be a dictionary.")
for key, val in value.items():
if not isinstance(val, bool):
raise serializers.ValidationError(f"Permission '{key}' must be a boolean.")
return value
def validate_limits(self, value):
"""Validate that limits is a dictionary with valid values"""
if not isinstance(value, dict):
raise serializers.ValidationError("Limits must be a dictionary.")
# Validate each key's value type
boolean_keys = [
'can_add_video_conferencing', 'can_connect_to_api', 'can_book_repeated_events',
'can_require_2fa', 'can_download_logs', 'can_delete_data',
'can_use_masked_phone_numbers', 'can_use_pos', 'can_use_mobile_app'
]
integer_keys = ['max_event_types', 'max_calendars_connected']
for key, val in value.items():
if key in boolean_keys and not isinstance(val, bool):
raise serializers.ValidationError(f"Limit '{key}' must be a boolean.")
if key in integer_keys and val is not None and not isinstance(val, int):
raise serializers.ValidationError(f"Limit '{key}' must be an integer or null.")
return value
class TenantInvitationCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating TenantInvitations - sets invited_by automatically"""
class Meta:
model = TenantInvitation
fields = [
'email', 'suggested_business_name', 'subscription_tier',
'custom_max_users', 'custom_max_resources', 'permissions', 'limits',
'personal_message',
]
def validate_limits(self, value):
"""Validate that limits is a dictionary with valid values"""
if not isinstance(value, dict):
raise serializers.ValidationError("Limits must be a dictionary.")
# Validate each key's value type
boolean_keys = [
'can_add_video_conferencing', 'can_connect_to_api', 'can_book_repeated_events',
'can_require_2fa', 'can_download_logs', 'can_delete_data',
'can_use_masked_phone_numbers', 'can_use_pos', 'can_use_mobile_app'
]
integer_keys = ['max_event_types', 'max_calendars_connected']
for key, val in value.items():
if key in boolean_keys and not isinstance(val, bool):
raise serializers.ValidationError(f"Limit '{key}' must be a boolean.")
if key in integer_keys and val is not None and not isinstance(val, int):
raise serializers.ValidationError(f"Limit '{key}' must be an integer or null.")
return value
def create(self, validated_data):
validated_data['invited_by'] = self.context['request'].user
return super().create(validated_data)
class TenantInvitationAcceptSerializer(serializers.Serializer):
"""Serializer for accepting a TenantInvitation"""
email = serializers.EmailField()
password = serializers.CharField(max_length=128, write_only=True)
first_name = serializers.CharField(max_length=150)
last_name = serializers.CharField(max_length=150)
business_name = serializers.CharField(max_length=100)
subdomain = serializers.CharField(max_length=63)
contact_email = serializers.EmailField(required=False, allow_blank=True)
phone = serializers.CharField(max_length=20, required=False, allow_blank=True)
def validate_subdomain(self, value):
"""Validate subdomain is unique and valid"""
import re
from core.models import Tenant, Domain
# Check format (lowercase alphanumeric and hyphens, must start with letter)
if not re.match(r'^[a-z][a-z0-9-]*$', value.lower()):
raise serializers.ValidationError(
"Subdomain must start with a letter and contain only lowercase letters, numbers, and hyphens"
)
# Check if subdomain already exists as schema_name
if Tenant.objects.filter(schema_name=value.lower()).exists():
raise serializers.ValidationError("This subdomain is already taken")
# Check if domain already exists
domain_name = f"{value.lower()}.lvh.me" # TODO: Make base domain configurable
if Domain.objects.filter(domain=domain_name).exists():
raise serializers.ValidationError("This subdomain is already taken")
# Reserved subdomains
reserved = ['www', 'api', 'admin', 'platform', 'app', 'mail', 'smtp', 'ftp', 'public']
if value.lower() in reserved:
raise serializers.ValidationError("This subdomain is reserved")
return value.lower()
def validate_email(self, value):
"""Validate email is unique for owner user"""
if User.objects.filter(email=value).exists():
raise serializers.ValidationError("A user with this email already exists.")
return value
class TenantInvitationDetailSerializer(TenantInvitationSerializer):
"""Serializer to display invitation details without requiring authentication"""
class Meta(TenantInvitationSerializer.Meta):
read_only_fields = ['id', 'email', 'token', 'status', 'suggested_business_name',
'subscription_tier', 'custom_max_users', 'custom_max_resources',
'permissions', 'personal_message', 'created_at', 'expires_at',
'accepted_at', 'created_tenant', 'created_user', 'invited_by_email',
'created_tenant_name', 'created_user_email']
extra_kwargs = {
'invited_by': {'read_only': True},
}
class AssignedUserSerializer(serializers.Serializer):
"""Lightweight serializer for assigned user info."""
id = serializers.IntegerField(read_only=True)
email = serializers.EmailField(read_only=True)
first_name = serializers.CharField(read_only=True)
last_name = serializers.CharField(read_only=True)
full_name = serializers.SerializerMethodField()
def get_full_name(self, obj):
return obj.get_full_name() or obj.email
class PlatformEmailAddressListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for listing platform email addresses."""
email_address = serializers.ReadOnlyField()
effective_sender_name = serializers.ReadOnlyField()
assigned_user = AssignedUserSerializer(read_only=True)
class Meta:
model = PlatformEmailAddress
fields = [
'id', 'display_name', 'sender_name', 'effective_sender_name',
'local_part', 'domain', 'email_address', 'color',
'assigned_user',
'is_active', 'is_default', 'mail_server_synced',
'last_check_at', 'emails_processed_count',
'created_at', 'updated_at'
]
read_only_fields = [
'email_address', 'effective_sender_name', 'mail_server_synced',
'last_check_at', 'emails_processed_count',
'created_at', 'updated_at'
]
class PlatformEmailAddressSerializer(serializers.ModelSerializer):
"""Full serializer for platform email addresses."""
email_address = serializers.ReadOnlyField()
effective_sender_name = serializers.ReadOnlyField()
assigned_user = AssignedUserSerializer(read_only=True)
assigned_user_id = serializers.IntegerField(
write_only=True,
required=False,
allow_null=True
)
imap_settings = serializers.SerializerMethodField()
smtp_settings = serializers.SerializerMethodField()
class Meta:
model = PlatformEmailAddress
fields = [
'id', 'display_name', 'sender_name', 'effective_sender_name',
'local_part', 'domain', 'email_address', 'color',
'assigned_user', 'assigned_user_id',
'password', 'is_active', 'is_default',
'mail_server_synced', 'last_sync_error', 'last_synced_at',
'last_check_at', 'emails_processed_count',
'created_at', 'updated_at',
'imap_settings', 'smtp_settings'
]
read_only_fields = [
'email_address', 'effective_sender_name',
'mail_server_synced', 'last_sync_error', 'last_synced_at',
'last_check_at', 'emails_processed_count',
'created_at', 'updated_at',
'imap_settings', 'smtp_settings'
]
extra_kwargs = {
'password': {'write_only': True},
}
def validate_assigned_user_id(self, value):
"""Validate and convert assigned_user_id to User instance."""
if value is None:
return None
from smoothschedule.users.models import User
try:
user = User.objects.get(
pk=value,
role__in=['superuser', 'platform_manager', 'platform_support'],
is_active=True
)
return user
except User.DoesNotExist:
raise serializers.ValidationError("User not found or not a platform user.")
def create(self, validated_data):
assigned_user = validated_data.pop('assigned_user_id', None)
instance = super().create(validated_data)
if assigned_user is not None:
instance.assigned_user = assigned_user
instance.save(update_fields=['assigned_user'])
return instance
def update(self, instance, validated_data):
if 'assigned_user_id' in validated_data:
instance.assigned_user = validated_data.pop('assigned_user_id')
return super().update(instance, validated_data)
def get_imap_settings(self, obj):
"""Return IMAP settings without password."""
settings = obj.get_imap_settings()
settings.pop('password', None)
return settings
def get_smtp_settings(self, obj):
"""Return SMTP settings without password."""
settings = obj.get_smtp_settings()
settings.pop('password', None)
return settings
def validate_local_part(self, value):
"""Validate local part of email address."""
import re
value = value.lower().strip()
# Check format
if not re.match(r'^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$', value):
raise serializers.ValidationError(
"Local part must start and end with a letter or number, "
"and can only contain letters, numbers, dots, underscores, and hyphens"
)
if len(value) > 64:
raise serializers.ValidationError("Local part cannot exceed 64 characters")
return value
def validate_password(self, value):
"""Validate password strength."""
if len(value) < 8:
raise serializers.ValidationError("Password must be at least 8 characters")
return value
def validate(self, attrs):
"""Cross-field validation."""
local_part = attrs.get('local_part', getattr(self.instance, 'local_part', None))
domain = attrs.get('domain', getattr(self.instance, 'domain', None))
if local_part and domain:
# Check uniqueness
qs = PlatformEmailAddress.objects.filter(
local_part=local_part.lower(),
domain=domain
)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise serializers.ValidationError({
'local_part': f'Email address {local_part}@{domain} already exists'
})
return attrs
class PlatformEmailAddressCreateSerializer(PlatformEmailAddressSerializer):
"""Serializer for creating platform email addresses with mail server sync."""
def create(self, validated_data):
"""Create the email address and sync to mail server."""
from .mail_server import get_mail_server_service
# Normalize local_part
validated_data['local_part'] = validated_data['local_part'].lower()
# Create the database record first
instance = super().create(validated_data)
# Sync to mail server
service = get_mail_server_service()
success, message = service.sync_account(instance)
if not success:
# Delete the database record if mail server sync failed
instance.delete()
raise serializers.ValidationError({
'mail_server': f'Failed to create email account on mail server: {message}'
})
return instance
class PlatformEmailAddressUpdateSerializer(serializers.ModelSerializer):
"""Serializer for updating platform email addresses."""
email_address = serializers.ReadOnlyField()
assigned_user_id = serializers.IntegerField(
write_only=True,
required=False,
allow_null=True
)
class Meta:
model = PlatformEmailAddress
fields = [
'id', 'display_name', 'sender_name', 'email_address', 'color',
'assigned_user_id',
'password', 'is_active', 'is_default'
]
read_only_fields = ['id', 'email_address']
extra_kwargs = {
'password': {'write_only': True, 'required': False},
'sender_name': {'required': False},
}
def validate_assigned_user_id(self, value):
"""Validate and convert assigned_user_id to User instance."""
if value is None:
return None
from smoothschedule.users.models import User
try:
user = User.objects.get(
pk=value,
role__in=['superuser', 'platform_manager', 'platform_support'],
is_active=True
)
return user
except User.DoesNotExist:
raise serializers.ValidationError("User not found or not a platform user.")
def validate_password(self, value):
"""Validate password strength if provided."""
if value and len(value) < 8:
raise serializers.ValidationError("Password must be at least 8 characters")
return value
def update(self, instance, validated_data):
"""Update the email address and sync password to mail server if changed."""
from .mail_server import get_mail_server_service
password = validated_data.get('password')
password_changed = password and password != instance.password
# Handle assigned_user_id separately
if 'assigned_user_id' in validated_data:
instance.assigned_user = validated_data.pop('assigned_user_id')
# Update the instance
instance = super().update(instance, validated_data)
# Sync to mail server if password changed
if password_changed:
service = get_mail_server_service()
success, message = service.sync_account(instance)
if not success:
raise serializers.ValidationError({
'mail_server': f'Failed to update password on mail server: {message}'
})
return instance