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:
@@ -8,13 +8,14 @@ from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from .models import Resource, Event, Participant, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin
|
||||
from .models import Resource, Event, Participant, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate
|
||||
from .serializers import (
|
||||
ResourceSerializer, EventSerializer, ParticipantSerializer,
|
||||
CustomerSerializer, ServiceSerializer, ResourceTypeSerializer, StaffSerializer,
|
||||
ScheduledTaskSerializer, TaskExecutionLogSerializer, PluginInfoSerializer,
|
||||
PluginTemplateSerializer, PluginTemplateListSerializer, PluginInstallationSerializer,
|
||||
EventPluginSerializer, GlobalEventPluginSerializer
|
||||
EventPluginSerializer, GlobalEventPluginSerializer,
|
||||
EmailTemplateSerializer, EmailTemplateListSerializer, EmailTemplatePreviewSerializer
|
||||
)
|
||||
from .models import Service
|
||||
from core.permissions import HasQuota
|
||||
@@ -1240,3 +1241,193 @@ class GlobalEventPluginViewSet(viewsets.ModelViewSet):
|
||||
{'value': 60, 'label': '1 hour'},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
class EmailTemplateViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for managing email templates.
|
||||
|
||||
Email templates can be used by plugins to send customized emails.
|
||||
Templates support variable substitution for dynamic content.
|
||||
|
||||
Access Control:
|
||||
- Business users see only BUSINESS scope templates (their own tenant's)
|
||||
- Platform users can also see/create PLATFORM scope templates (shared)
|
||||
|
||||
Endpoints:
|
||||
- GET /api/email-templates/ - List templates (filtered by scope/category)
|
||||
- POST /api/email-templates/ - Create template
|
||||
- GET /api/email-templates/{id}/ - Get template details
|
||||
- PATCH /api/email-templates/{id}/ - Update template
|
||||
- DELETE /api/email-templates/{id}/ - Delete template
|
||||
- POST /api/email-templates/preview/ - Render preview with sample data
|
||||
- POST /api/email-templates/{id}/duplicate/ - Create a copy
|
||||
- GET /api/email-templates/variables/ - Get available template variables
|
||||
"""
|
||||
queryset = EmailTemplate.objects.all()
|
||||
serializer_class = EmailTemplateSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter templates based on user type and query params"""
|
||||
user = self.request.user
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Platform users see all templates
|
||||
if hasattr(user, 'is_platform_user') and user.is_platform_user:
|
||||
scope = self.request.query_params.get('scope')
|
||||
if scope:
|
||||
queryset = queryset.filter(scope=scope.upper())
|
||||
else:
|
||||
# Business users only see BUSINESS scope templates
|
||||
queryset = queryset.filter(scope=EmailTemplate.Scope.BUSINESS)
|
||||
|
||||
# Filter by category if specified
|
||||
category = self.request.query_params.get('category')
|
||||
if category:
|
||||
queryset = queryset.filter(category=category.upper())
|
||||
|
||||
return queryset.order_by('name')
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Use lightweight serializer for list view"""
|
||||
if self.action == 'list':
|
||||
return EmailTemplateListSerializer
|
||||
return EmailTemplateSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set created_by from request user"""
|
||||
serializer.save(created_by=self.request.user)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def preview(self, request):
|
||||
"""
|
||||
Render a preview of the template with sample data.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"subject": "Hello {{CUSTOMER_NAME}}",
|
||||
"html_content": "<p>Your appointment is on {{APPOINTMENT_DATE}}</p>",
|
||||
"text_content": "Your appointment is on {{APPOINTMENT_DATE}}",
|
||||
"context": {"CUSTOMER_NAME": "John"} // optional overrides
|
||||
}
|
||||
|
||||
Response includes rendered content with force_footer flag for free tier.
|
||||
"""
|
||||
serializer = EmailTemplatePreviewSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
from .template_parser import TemplateVariableParser
|
||||
from datetime import datetime
|
||||
|
||||
context = serializer.validated_data.get('context', {})
|
||||
subject = serializer.validated_data['subject']
|
||||
html = serializer.validated_data.get('html_content', '')
|
||||
text = serializer.validated_data.get('text_content', '')
|
||||
|
||||
# Add default sample values for preview
|
||||
default_context = {
|
||||
'BUSINESS_NAME': 'Demo Business',
|
||||
'BUSINESS_EMAIL': 'contact@demo.com',
|
||||
'BUSINESS_PHONE': '(555) 123-4567',
|
||||
'CUSTOMER_NAME': 'John Doe',
|
||||
'CUSTOMER_EMAIL': 'john@example.com',
|
||||
'APPOINTMENT_TIME': 'Monday, January 15, 2025 at 2:00 PM',
|
||||
'APPOINTMENT_DATE': 'January 15, 2025',
|
||||
'APPOINTMENT_SERVICE': 'Consultation',
|
||||
'TODAY': datetime.now().strftime('%B %d, %Y'),
|
||||
'NOW': datetime.now().strftime('%B %d, %Y at %I:%M %p'),
|
||||
}
|
||||
default_context.update(context)
|
||||
|
||||
rendered_subject = TemplateVariableParser.replace_insertion_codes(subject, default_context)
|
||||
rendered_html = TemplateVariableParser.replace_insertion_codes(html, default_context) if html else ''
|
||||
rendered_text = TemplateVariableParser.replace_insertion_codes(text, default_context) if text else ''
|
||||
|
||||
# Check if free tier - append footer
|
||||
force_footer = False
|
||||
user = request.user
|
||||
if hasattr(user, 'is_platform_user') and not user.is_platform_user:
|
||||
from django.db import connection
|
||||
if hasattr(connection, 'tenant') and connection.tenant.subscription_tier == 'FREE':
|
||||
force_footer = True
|
||||
|
||||
if force_footer:
|
||||
# Create a temporary instance just to use the footer methods
|
||||
temp = EmailTemplate()
|
||||
rendered_html = temp._append_html_footer(rendered_html)
|
||||
rendered_text = temp._append_text_footer(rendered_text)
|
||||
|
||||
return Response({
|
||||
'subject': rendered_subject,
|
||||
'html_content': rendered_html,
|
||||
'text_content': rendered_text,
|
||||
'force_footer': force_footer,
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def duplicate(self, request, pk=None):
|
||||
"""
|
||||
Create a copy of an existing template.
|
||||
|
||||
The copy will have "(Copy)" appended to its name.
|
||||
"""
|
||||
template = self.get_object()
|
||||
|
||||
new_template = EmailTemplate.objects.create(
|
||||
name=f"{template.name} (Copy)",
|
||||
description=template.description,
|
||||
subject=template.subject,
|
||||
html_content=template.html_content,
|
||||
text_content=template.text_content,
|
||||
scope=template.scope,
|
||||
category=template.category,
|
||||
preview_context=template.preview_context,
|
||||
created_by=request.user,
|
||||
)
|
||||
|
||||
serializer = EmailTemplateSerializer(new_template)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def variables(self, request):
|
||||
"""
|
||||
Get available template variables for the email template editor.
|
||||
|
||||
Returns variables grouped by category with descriptions.
|
||||
"""
|
||||
return Response({
|
||||
'variables': [
|
||||
{
|
||||
'category': 'Business',
|
||||
'items': [
|
||||
{'code': '{{BUSINESS_NAME}}', 'description': 'Business name'},
|
||||
{'code': '{{BUSINESS_EMAIL}}', 'description': 'Business contact email'},
|
||||
{'code': '{{BUSINESS_PHONE}}', 'description': 'Business phone number'},
|
||||
]
|
||||
},
|
||||
{
|
||||
'category': 'Customer',
|
||||
'items': [
|
||||
{'code': '{{CUSTOMER_NAME}}', 'description': 'Customer full name'},
|
||||
{'code': '{{CUSTOMER_EMAIL}}', 'description': 'Customer email address'},
|
||||
]
|
||||
},
|
||||
{
|
||||
'category': 'Appointment',
|
||||
'items': [
|
||||
{'code': '{{APPOINTMENT_TIME}}', 'description': 'Full date and time'},
|
||||
{'code': '{{APPOINTMENT_DATE}}', 'description': 'Date only'},
|
||||
{'code': '{{APPOINTMENT_SERVICE}}', 'description': 'Service name'},
|
||||
]
|
||||
},
|
||||
{
|
||||
'category': 'Date/Time',
|
||||
'items': [
|
||||
{'code': '{{TODAY}}', 'description': 'Current date'},
|
||||
{'code': '{{NOW}}', 'description': 'Current date and time'},
|
||||
]
|
||||
},
|
||||
],
|
||||
'categories': [choice[0] for choice in EmailTemplate.Category.choices],
|
||||
})
|
||||
Reference in New Issue
Block a user