feat: Add SMTP settings and collapsible email configuration UI
- Add SMTP fields to TicketEmailSettings model (host, port, TLS/SSL, credentials, from email/name) - Update serializers with SMTP fields and is_smtp_configured flag - Add TicketEmailTestSmtpView for testing SMTP connections - Update frontend API types and hooks for SMTP settings - Add collapsible IMAP and SMTP configuration sections with "Configured" badges - Fix TypeScript errors in mockData.ts (missing required fields, type mismatches) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,161 @@ 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
|
||||
from .models import TenantInvitation, PlatformSettings, SubscriptionPlan
|
||||
|
||||
|
||||
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()
|
||||
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', 'transaction_fee_percent', 'transaction_fee_fixed',
|
||||
'is_active', 'is_public', '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', 'transaction_fee_percent', 'transaction_fee_fixed',
|
||||
'is_active', 'is_public', '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):
|
||||
@@ -289,7 +443,7 @@ class TenantInvitationSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'id', 'email', 'token', 'status', 'suggested_business_name',
|
||||
'subscription_tier', 'custom_max_users', 'custom_max_resources',
|
||||
'permissions', 'personal_message', 'invited_by',
|
||||
'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',
|
||||
]
|
||||
@@ -309,6 +463,27 @@ class TenantInvitationSerializer(serializers.ModelSerializer):
|
||||
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"""
|
||||
@@ -316,10 +491,31 @@ class TenantInvitationCreateSerializer(serializers.ModelSerializer):
|
||||
model = TenantInvitation
|
||||
fields = [
|
||||
'email', 'suggested_business_name', 'subscription_tier',
|
||||
'custom_max_users', 'custom_max_resources', 'permissions',
|
||||
'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)
|
||||
|
||||
Reference in New Issue
Block a user