Files
smoothschedule/smoothschedule/platform_admin/tasks.py
poduck cfc1b36ada 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>
2025-11-29 18:28:29 -05:00

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}