- Add PlatformEmailAddress model for managing platform-level email addresses - Add TicketEmailAddress model for tenant-level email addresses - Create MailServerService for IMAP integration with mail.talova.net - Implement PlatformEmailReceiver for processing incoming platform emails - Add email autoconfiguration for Mozilla, Microsoft, and Apple clients - Add configurable email polling interval in platform settings - Add "Check Emails" button on support page for manual refresh - Add ticket counts to status tabs on support page - Add platform email addresses management page - Add Privacy Policy and Terms of Service pages - Add robots.txt for SEO - Restrict email addresses to smoothschedule.com domain only 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
839 lines
34 KiB
Python
839 lines
34 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',
|
|
'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',
|
|
'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
|
|
|