- 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>
210 lines
8.0 KiB
Python
210 lines
8.0 KiB
Python
"""
|
|
Celery tasks for platform admin operations.
|
|
"""
|
|
from celery import shared_task
|
|
from django.core.mail import send_mail, EmailMultiAlternatives
|
|
from django.template.loader import render_to_string
|
|
from django.conf import settings
|
|
from django.utils.html import strip_tags
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_base_url():
|
|
"""Get the base URL for the platform."""
|
|
# In production, this should come from settings
|
|
return getattr(settings, 'PLATFORM_BASE_URL', 'http://platform.lvh.me:5173')
|
|
|
|
|
|
@shared_task(bind=True, max_retries=3)
|
|
def send_tenant_invitation_email(self, invitation_id: int):
|
|
"""
|
|
Send an invitation email to a prospective tenant.
|
|
|
|
Args:
|
|
invitation_id: ID of the TenantInvitation to send
|
|
"""
|
|
from .models import TenantInvitation
|
|
|
|
try:
|
|
invitation = TenantInvitation.objects.select_related('invited_by').get(id=invitation_id)
|
|
except TenantInvitation.DoesNotExist:
|
|
logger.error(f"TenantInvitation {invitation_id} not found")
|
|
return {'success': False, 'error': 'Invitation not found'}
|
|
|
|
# Don't send if already accepted or expired
|
|
if invitation.status != TenantInvitation.Status.PENDING:
|
|
logger.info(f"Skipping email for invitation {invitation_id} - status is {invitation.status}")
|
|
return {'success': False, 'error': f'Invitation status is {invitation.status}'}
|
|
|
|
if not invitation.is_valid():
|
|
logger.info(f"Skipping email for invitation {invitation_id} - invitation expired")
|
|
return {'success': False, 'error': 'Invitation expired'}
|
|
|
|
try:
|
|
# Build the invitation URL
|
|
base_url = get_base_url()
|
|
invitation_url = f"{base_url}/accept-invite/{invitation.token}"
|
|
|
|
# Email context
|
|
context = {
|
|
'invitation': invitation,
|
|
'invitation_url': invitation_url,
|
|
'inviter_name': invitation.invited_by.get_full_name() or invitation.invited_by.email if invitation.invited_by else 'SmoothSchedule Team',
|
|
'suggested_business_name': invitation.suggested_business_name or 'your new business',
|
|
'personal_message': invitation.personal_message,
|
|
'expires_at': invitation.expires_at,
|
|
}
|
|
|
|
# Render HTML email
|
|
html_content = render_to_string('platform_admin/emails/tenant_invitation.html', context)
|
|
text_content = strip_tags(html_content)
|
|
|
|
# Subject line
|
|
subject = f"You're invited to join SmoothSchedule!"
|
|
if invitation.invited_by:
|
|
inviter = invitation.invited_by.get_full_name() or invitation.invited_by.email
|
|
subject = f"{inviter} has invited you to SmoothSchedule"
|
|
|
|
# Send email
|
|
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
|
|
|
|
email = EmailMultiAlternatives(
|
|
subject=subject,
|
|
body=text_content,
|
|
from_email=from_email,
|
|
to=[invitation.email],
|
|
)
|
|
email.attach_alternative(html_content, "text/html")
|
|
email.send()
|
|
|
|
logger.info(f"Sent invitation email to {invitation.email} for invitation {invitation_id}")
|
|
return {'success': True, 'email': invitation.email}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send invitation email for {invitation_id}: {e}", exc_info=True)
|
|
# Retry with exponential backoff
|
|
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
|
|
|
|
|
|
@shared_task(bind=True, max_retries=3)
|
|
def send_appointment_reminder_email(self, event_id: int, customer_email: str, hours_before: int = 24):
|
|
"""
|
|
Send an appointment reminder email to a customer.
|
|
|
|
Args:
|
|
event_id: ID of the Event
|
|
customer_email: Email address to send reminder to
|
|
hours_before: Hours before the appointment (for context in email)
|
|
"""
|
|
from schedule.models import Event
|
|
|
|
try:
|
|
event = Event.objects.select_related('created_by').prefetch_related(
|
|
'participants'
|
|
).get(id=event_id)
|
|
except Event.DoesNotExist:
|
|
logger.error(f"Event {event_id} not found")
|
|
return {'success': False, 'error': 'Event not found'}
|
|
|
|
# Don't send for cancelled events
|
|
if event.status == Event.Status.CANCELED:
|
|
logger.info(f"Skipping reminder for event {event_id} - event is cancelled")
|
|
return {'success': False, 'error': 'Event cancelled'}
|
|
|
|
try:
|
|
# Get resources/staff for the event
|
|
staff_names = []
|
|
for participant in event.participants.filter(role__in=['RESOURCE', 'STAFF']):
|
|
if participant.content_object and hasattr(participant.content_object, 'name'):
|
|
staff_names.append(participant.content_object.name)
|
|
|
|
# Get business info from tenant
|
|
from django.db import connection
|
|
business_name = 'SmoothSchedule'
|
|
if hasattr(connection, 'tenant'):
|
|
business_name = connection.tenant.name
|
|
|
|
# Email context
|
|
context = {
|
|
'event': event,
|
|
'customer_email': customer_email,
|
|
'business_name': business_name,
|
|
'staff_names': staff_names,
|
|
'hours_before': hours_before,
|
|
'event_date': event.start_time.strftime('%A, %B %d, %Y'),
|
|
'event_time': event.start_time.strftime('%I:%M %p'),
|
|
'duration_minutes': int(event.duration.total_seconds() // 60) if hasattr(event.duration, 'total_seconds') else event.duration,
|
|
}
|
|
|
|
# Render HTML email
|
|
html_content = render_to_string('schedule/emails/appointment_reminder.html', context)
|
|
text_content = strip_tags(html_content)
|
|
|
|
# Subject line
|
|
subject = f"Reminder: Your appointment at {business_name} on {context['event_date']}"
|
|
|
|
# Send email
|
|
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
|
|
|
|
email = EmailMultiAlternatives(
|
|
subject=subject,
|
|
body=text_content,
|
|
from_email=from_email,
|
|
to=[customer_email],
|
|
)
|
|
email.attach_alternative(html_content, "text/html")
|
|
email.send()
|
|
|
|
logger.info(f"Sent appointment reminder to {customer_email} for event {event_id}")
|
|
return {'success': True, 'email': customer_email, 'event_id': event_id}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send appointment reminder for event {event_id}: {e}", exc_info=True)
|
|
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
|
|
|
|
|
|
@shared_task
|
|
def send_bulk_appointment_reminders(hours_before: int = 24):
|
|
"""
|
|
Find all appointments happening in X hours and send reminder emails.
|
|
|
|
This is typically run periodically by Celery Beat.
|
|
|
|
Args:
|
|
hours_before: How many hours before the appointment to send reminders
|
|
"""
|
|
from schedule.models import Event
|
|
from django.utils import timezone
|
|
from datetime import timedelta
|
|
|
|
now = timezone.now()
|
|
reminder_window_start = now + timedelta(hours=hours_before - 0.5)
|
|
reminder_window_end = now + timedelta(hours=hours_before + 0.5)
|
|
|
|
# Find events in the reminder window
|
|
events = Event.objects.filter(
|
|
start_time__gte=reminder_window_start,
|
|
start_time__lte=reminder_window_end,
|
|
status=Event.Status.SCHEDULED,
|
|
).prefetch_related('participants__customer')
|
|
|
|
reminders_queued = 0
|
|
for event in events:
|
|
# Get customer emails from participants
|
|
for participant in event.participants.all():
|
|
if participant.customer and hasattr(participant.customer, 'email'):
|
|
customer_email = participant.customer.email
|
|
if customer_email:
|
|
# Queue the reminder email
|
|
send_appointment_reminder_email.delay(
|
|
event_id=event.id,
|
|
customer_email=customer_email,
|
|
hours_before=hours_before
|
|
)
|
|
reminders_queued += 1
|
|
|
|
logger.info(f"Queued {reminders_queued} appointment reminder emails for {hours_before}h window")
|
|
return {'reminders_queued': reminders_queued, 'hours_before': hours_before}
|