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:
poduck
2025-11-29 18:28:29 -05:00
parent 0c7d76e264
commit cfc1b36ada
94 changed files with 13419 additions and 1121 deletions

133
TODO_EMAIL_AND_MESSAGING.md Normal file
View File

@@ -0,0 +1,133 @@
# Email Templates & Messaging TODO
## Completed Features
### Email Template System (Implemented)
- [x] EmailTemplate model with BUSINESS and PLATFORM scopes
- [x] Categories: Appointment, Reminder, Confirmation, Marketing, Notification, Report, Other
- [x] Template variables: `{{BUSINESS_NAME}}`, `{{CUSTOMER_NAME}}`, `{{APPOINTMENT_DATE}}`, etc.
- [x] Backend API (CRUD, preview, duplicate)
- [x] Frontend Email Templates page with list/create/edit/delete
- [x] EmailTemplateForm component with code editor and preview
- [x] EmailTemplateSelector component for plugin configuration
- [x] Plugin config support for `email_template` type variables
- [x] Footer enforcement for FREE tier tenants
- [x] Default templates seed command (`python manage.py seed_email_templates`)
### Default Email Templates Created
- [x] Appointment Confirmation
- [x] Appointment Reminder - 24 Hours
- [x] Appointment Reminder - 1 Hour
- [x] Appointment Rescheduled
- [x] Appointment Cancelled
- [x] Thank You - Appointment Complete
- [x] Welcome New Customer
### Ticket Email Templates (Added)
- [x] Ticket Assigned - Notification to staff when assigned
- [x] Ticket Status Changed - Status update notification
- [x] Ticket Reply - Staff Notification - When customer replies
- [x] Ticket Reply - Customer Notification - When staff replies
- [x] Ticket Resolved - Resolution confirmation
### Plugin Documentation Updated
- [x] Documented `email_template` variable type in SCHEDULER_PLUGIN_SYSTEM.md
- [x] Added template variable types table (text, textarea, email, number, url, email_template)
- [x] Added ticket-related insertion codes to template_parser.py
---
## Pending Features
### Customer Onboarding Email Process
- [ ] Welcome email when customer creates account
- [ ] Email verification workflow
- [ ] First booking celebration email
- [ ] Loyalty program introduction email (if enabled)
### Internal Staff Messaging System
- [ ] Message model (sender, recipient, subject, body, read status)
- [ ] Conversation threading
- [ ] Messaging UI in sidebar/header
- [ ] Unread count badge
- [ ] Push notifications for new messages
- [ ] Email notification option for messages
### Ticket Email Notifications
- [x] Email templates created (see "Ticket Email Templates" in Completed)
- [x] Ticket email notification service (`tickets/email_notifications.py`)
- TicketEmailService class with methods for each notification type
- Renders email templates with ticket context variables
- Falls back to default text templates if custom templates not found
- Includes Reply-To header with ticket ID for email threading
- [x] Django signals to trigger emails on ticket events (`tickets/signals.py`)
- Pre-save handler to track state changes (assignee, status)
- Post-save handlers for assignment, status change, and resolution
- Comment handler for reply notifications
- [x] Reply-To header with ticket ID for inbound processing
- Format: `support+ticket-{id}@{domain}`
- X-Ticket-ID header for easy parsing
### Email Reply Forwarding (Inbound Email Processing)
- [ ] Setup inbound email handling (mail.talova.net integration)
- [ ] Parse incoming emails to support@smoothschedule.com (or per-tenant)
- [ ] Extract ticket ID from subject line or headers
- [ ] Create ticket reply from email content
- [ ] Notify assignee of new reply
- [ ] Handle attachments
---
## Email Server Details
Current email server: `mail.talova.net`
Test account: `poduck@mail.talova.net`
### Inbound Email Options
1. **Mailgun/SendGrid Inbound Routes**
- Forward to webhook endpoint
- Parse and process programmatically
- Best for scalability
2. **IMAP Polling**
- Connect to mail.talova.net via IMAP
- Poll for new messages periodically
- Process and delete/archive
3. **Email Piping**
- Configure MTA to pipe emails to a Django management command
- Real-time processing
- Requires server access
---
## Platform vs Business Templates
### Platform Templates (Scope: PLATFORM)
Used for system-wide emails:
- Tenant invitation
- Platform announcements
- System notifications
### Business Templates (Scope: BUSINESS)
Per-tenant customizable templates:
- Appointment confirmations
- Reminders
- Customer communications
- Marketing emails
---
## Running the Seed Command
```bash
# Seed templates for all tenant schemas
docker compose -f docker-compose.local.yml exec django python manage.py seed_email_templates
# Seed for specific schema
docker compose -f docker-compose.local.yml exec django python manage.py seed_email_templates --schema=demo
# Reset to defaults (overwrites existing)
docker compose -f docker-compose.local.yml exec django python manage.py seed_email_templates --reset
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 993 B

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 840 B

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,132 +0,0 @@
#!/usr/bin/env python3
from google import genai
from google.genai import types
from PIL import Image
from io import BytesIO
import os
import time
# Setup Client
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
# Plugin configurations with detailed prompts
plugins = [
{
'filename': 'daily-appointment-summary.png',
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
Design: Indigo blue gradient background (#4f46e5 to lighter blue).
Icon: Simple white envelope overlaid with a small calendar icon in the corner.
Style: Flat design, clean geometric shapes, professional SaaS aesthetic.
The icon should be crisp and recognizable at 48x48 pixels.'''
},
{
'filename': 'no-show-tracker.png',
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
Design: Red gradient background (#dc2626 to darker red).
Icon: Simple white person silhouette with a bold white X symbol overlaid.
Style: Flat design, clean geometric shapes, professional warning aesthetic.
The icon should clearly convey "missed appointment" at small sizes.'''
},
{
'filename': 'birthday-greetings.png',
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
Design: Pink gradient background (#ec4899 to lighter pink).
Icon: Simple white birthday cake with 3 candles or a white gift box with a bow.
Style: Flat design, clean geometric shapes, cheerful yet professional aesthetic.
The icon should feel celebratory and friendly at small sizes.'''
},
{
'filename': 'monthly-revenue-report.png',
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
Design: Green gradient background (#10b981 to darker green).
Icon: Simple white upward trending line chart or ascending bar graph.
Style: Flat design, clean geometric shapes, professional business analytics aesthetic.
The icon should convey growth and success at small sizes.'''
},
{
'filename': 'appointment-reminder-24hr.png',
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
Design: Amber/orange gradient background (#f59e0b to darker orange).
Icon: Simple white notification bell with a small red circular alert badge.
Style: Flat design, clean geometric shapes, attention-grabbing yet professional aesthetic.
The icon should clearly indicate alerts and reminders at small sizes.'''
},
{
'filename': 'inactive-customer-reengagement.png',
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
Design: Purple gradient background (#8b5cf6 to darker purple).
Icon: Simple white heart symbol with a circular white refresh/return arrow around it.
Style: Flat design, clean geometric shapes, warm and welcoming professional aesthetic.
The icon should convey customer care and returning customers at small sizes.'''
}
]
def generate_logo(prompt, filename):
"""Generate a logo using Gemini 2.5 Flash Image"""
print(f"\nGenerating {filename}...")
try:
response = client.models.generate_content(
model='gemini-2.5-flash-image',
contents=prompt,
config=types.GenerateContentConfig(
response_modalities=["IMAGE"]
)
)
# Save the Image
if response.candidates[0].content.parts:
for part in response.candidates[0].content.parts:
if part.inline_data:
image_data = part.inline_data.data
image = Image.open(BytesIO(image_data))
# Save to output directory
output_path = os.path.join(OUTPUT_DIR, filename)
image.save(output_path)
print(f"✓ Saved: {output_path}")
return True
else:
print(f"✗ Model returned text instead of image: {part.text[:100]}")
return False
else:
print("✗ No content parts in response")
return False
except Exception as e:
error_msg = str(e)
if "RESOURCE_EXHAUSTED" in error_msg or "quota" in error_msg.lower():
print(f"✗ Quota exceeded. Please try again tomorrow when quota resets.")
print(f" Error: {error_msg[:200]}...")
else:
print(f"✗ Error: {type(e).__name__}: {error_msg[:200]}")
return False
def main():
os.makedirs(OUTPUT_DIR, exist_ok=True)
print("=" * 70)
print("Generating Plugin Logos with Gemini 2.5 Flash Image")
print("=" * 70)
success_count = 0
for i, plugin in enumerate(plugins):
if i > 0:
# Wait 3 seconds between requests to avoid rate limiting
print("\nWaiting 3 seconds before next request...")
time.sleep(3)
if generate_logo(plugin['prompt'], plugin['filename']):
success_count += 1
print("\n" + "=" * 70)
print(f"Generation complete: {success_count}/{len(plugins)} successful")
print("=" * 70)
if success_count == 0:
print("\nNote: If you hit quota limits, try again tomorrow when the quota resets!")
if __name__ == "__main__":
main()

View File

@@ -1,77 +0,0 @@
import os
import sys
try:
from PIL import Image, ImageDraw
except ImportError:
print("Error: Pillow library not found.")
print("Please install it using: pip install Pillow")
sys.exit(1)
# Configuration
OUTPUT_PATH = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos/daily-appointment-summary.png"
SIZE = (144, 144) # Generated at 3x resolution (144px) for high quality on 48px displays
BG_COLOR = "#4f46e5" # Indigo/Blue
ICON_COLOR = "white"
def create_logo():
# Create a new image with a transparent background
img = Image.new('RGBA', SIZE, (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# 1. Draw Background (Rounded Square)
radius = 30
rect_bounds = [0, 0, SIZE[0], SIZE[1]]
draw.rounded_rectangle(rect_bounds, radius=radius, fill=BG_COLOR)
# 2. Draw Envelope Icon (Email concept)
# Centered, slightly offset up to make room for calendar
env_w = 90
env_h = 60
env_x = (SIZE[0] - env_w) // 2
env_y = (SIZE[1] - env_h) // 2 - 10
# Envelope body
draw.rectangle([env_x, env_y, env_x + env_w, env_y + env_h], outline=ICON_COLOR, width=5)
# Envelope flap (V shape)
draw.line([env_x, env_y, env_x + env_w // 2, env_y + env_h // 2 + 5], fill=ICON_COLOR, width=5)
draw.line([env_x + env_w, env_y, env_x + env_w // 2, env_y + env_h // 2 + 5], fill=ICON_COLOR, width=5)
# 3. Draw Calendar Badge (Scheduling concept)
# Bottom right corner
cal_size = 50
cal_x = env_x + env_w - (cal_size // 2)
cal_y = env_y + env_h - (cal_size // 2)
# Clear background behind calendar for separation
border = 4
draw.rounded_rectangle(
[cal_x - border, cal_y - border, cal_x + cal_size + border, cal_y + cal_size + border],
radius=10, fill=BG_COLOR
)
# Calendar body
draw.rounded_rectangle([cal_x, cal_y, cal_x + cal_size, cal_y + cal_size], radius=8, fill="white")
# Calendar red header (using a lighter indigo/blue to match theme)
header_h = 14
draw.rounded_rectangle(
[cal_x, cal_y, cal_x + cal_size, cal_y + header_h],
radius=8, corners=(True, True, False, False), fill="#818cf8"
)
# Calendar 'lines'
line_x = cal_x + 10
line_w = cal_size - 20
draw.line([line_x, cal_y + 22, line_x + line_w, cal_y + 22], fill=BG_COLOR, width=3)
draw.line([line_x, cal_y + 32, line_x + line_w, cal_y + 32], fill=BG_COLOR, width=3)
draw.line([line_x, cal_y + 42, line_x + line_w * 0.6, cal_y + 42], fill=BG_COLOR, width=3)
# Save the file
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
img.save(OUTPUT_PATH)
print(f"Success! Logo saved to: {OUTPUT_PATH}")
if __name__ == "__main__":
create_logo()

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
Generate plugin icons using Gemini 2.5 Flash Image API.
Based on: https://ai.google.dev/gemini-api/docs/image-generation
"""
from google import genai
from PIL import Image
import os
import time
# API Key
client = genai.Client(api_key="AIzaSyBF3_KnttSerq2BK2CaRKSTJHN8BoXp6Hw")
OUTPUT_DIR = os.path.dirname(os.path.abspath(__file__))
# Plugin configurations with improved prompts
plugins = [
{
'filename': 'daily-appointment-summary.png',
'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners.
Style: Modern gradient background transitioning from deep indigo (#4338ca) to vibrant blue (#3b82f6).
Central element: A stylized white clipboard or document with 3 horizontal lines representing a list, and a small clock or sun symbol in the top right corner indicating "daily".
Design principles: Minimalist, clean lines, no text, suitable for a scheduling/calendar application. The icon should be instantly recognizable as "daily summary" or "daily report" at small sizes like 32x32.'''
},
{
'filename': 'no-show-tracker.png',
'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners.
Style: Modern gradient background from deep red (#b91c1c) to coral red (#ef4444).
Central element: A white calendar or appointment card with a prominent empty circle or "absent" symbol - perhaps a chair silhouette with a question mark, or a clock with a slash through it indicating a missed appointment.
Design principles: Minimalist, clean lines, no text. The icon should clearly communicate "missed" or "no-show" at a glance. Professional warning aesthetic without being alarming.'''
},
{
'filename': 'birthday-greetings.png',
'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners.
Style: Modern gradient background from magenta (#c026d3) to pink (#ec4899).
Central element: A white birthday cake with 2-3 simple candles, or a wrapped gift box with a ribbon bow. Add subtle confetti dots or small stars around it for a celebratory feel.
Design principles: Minimalist, cheerful but professional, clean lines, no text. Should feel warm and celebratory while still fitting a business application aesthetic.'''
},
{
'filename': 'monthly-revenue-report.png',
'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners.
Style: Modern gradient background from emerald green (#059669) to teal (#14b8a6).
Central element: A white line chart showing an upward trend with 3-4 data points connected by lines, or ascending bar chart with dollar sign integrated subtly. The graph should clearly show growth/success.
Design principles: Minimalist, professional finance/analytics aesthetic, clean lines, no text. Should instantly communicate "revenue" and "growth" at small sizes.'''
},
{
'filename': 'appointment-reminder-24hr.png',
'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners.
Style: Modern gradient background from amber (#d97706) to orange (#f97316).
Central element: A white notification bell with a small circular badge, combined with a "24" numeral or a clock face showing time. The bell should be the primary focus with the time element secondary.
Design principles: Minimalist, attention-grabbing but professional, clean lines, no text except possibly "24". Should clearly indicate "reminder" and "advance notice" at small sizes.'''
},
{
'filename': 'inactive-customer-reengagement.png',
'prompt': '''Generate a professional app icon, 512x512 pixels, square with rounded corners.
Style: Modern gradient background from violet (#7c3aed) to purple (#a855f7).
Central element: A white person silhouette or user icon with a curved "return" arrow circling back to them, or a magnet symbol attracting a small person icon. Should convey "bringing customers back".
Design principles: Minimalist, warm and welcoming professional aesthetic, clean lines, no text. Should communicate "re-engagement" or "win back" at a glance.'''
}
]
def generate_logo(prompt: str, filename: str) -> bool:
"""Generate a logo using Gemini 2.5 Flash Image (per official docs)"""
print(f"\nGenerating {filename}...")
try:
# Using the exact format from Google's documentation
response = client.models.generate_content(
model='gemini-2.5-flash-image',
contents=[prompt],
)
# Process response per docs
for part in response.parts:
if part.inline_data is not None:
# Get the raw image data and convert to PIL
from io import BytesIO
image_data = part.inline_data.data
pil_image = Image.open(BytesIO(image_data))
# Resize to 256x256 for consistency
if pil_image.size != (256, 256):
pil_image = pil_image.resize((256, 256), Image.Resampling.LANCZOS)
output_path = os.path.join(OUTPUT_DIR, filename)
pil_image.save(output_path, 'PNG')
print(f" ✓ Saved: {output_path}")
return True
elif part.text is not None:
print(f" Model text: {part.text[:100]}...")
print(" ✗ No image in response")
return False
except Exception as e:
error_msg = str(e)
if "RESOURCE_EXHAUSTED" in error_msg or "quota" in error_msg.lower():
print(f" ✗ Quota exceeded - try again later")
else:
print(f" ✗ Error: {type(e).__name__}: {error_msg[:300]}")
return False
def main():
print("=" * 60)
print("Generating Plugin Icons with Gemini 2.5 Flash Image")
print("=" * 60)
success_count = 0
for i, plugin in enumerate(plugins):
if i > 0:
print("\nWaiting 5 seconds...")
time.sleep(5)
if generate_logo(plugin['prompt'], plugin['filename']):
success_count += 1
print("\n" + "=" * 60)
print(f"Complete: {success_count}/{len(plugins)} icons generated")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -1,155 +0,0 @@
import os
import sys
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("Error: Pillow library not found.")
print("Please install it using: pip install Pillow")
sys.exit(1)
# Configuration
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
SIZE = (144, 144)
RADIUS = 30
def create_base_logo(bg_color):
"""Creates the base image with rounded corners and background color."""
img = Image.new('RGBA', SIZE, (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
rect_bounds = [0, 0, SIZE[0], SIZE[1]]
draw.rounded_rectangle(rect_bounds, radius=RADIUS, fill=bg_color)
return img, draw
def save_logo(img, filename):
"""Saves the image to the output directory."""
path = os.path.join(OUTPUT_DIR, filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
img.save(path)
print(f"Saved: {path}")
# 1. No-Show Customer Tracker (Red, Person with X)
def create_no_show_logo():
img, draw = create_base_logo("#dc2626") # Red
# Person Silhouette
cx, cy = 72, 72
# Head
draw.ellipse([cx-15, 35, cx+15, 65], fill="white")
# Shoulders/Body
draw.rounded_rectangle([cx-30, 70, cx+30, 130], radius=10, fill="white")
# Cancel Symbol (Red X on white circle or just overlaid)
# Let's use a thick Red X overlay
x_center_y = 85
half = 20
width = 8
# Draw X
draw.line([cx - half, x_center_y - half, cx + half, x_center_y + half], fill="#dc2626", width=width)
draw.line([cx + half, x_center_y - half, cx - half, x_center_y + half], fill="#dc2626", width=width)
save_logo(img, "no-show-tracker.png")
# 2. Birthday Greeting Campaign (Pink, Gift)
def create_birthday_logo():
img, draw = create_base_logo("#ec4899") # Pink
# Gift Box
box_w, box_h = 70, 60
box_x = (SIZE[0] - box_w) // 2
box_y = (SIZE[1] - box_h) // 2 + 10
# Box body
draw.rectangle([box_x, box_y, box_x + box_w, box_y + box_h], fill="white")
# Ribbons (Lighter Pink)
r_w = 12
ribbon_color = "#fbcfe8"
# Vertical
draw.rectangle([box_x + (box_w - r_w)//2, box_y, box_x + (box_w + r_w)//2, box_y + box_h], fill=ribbon_color)
# Horizontal
draw.rectangle([box_x, box_y + (box_h - r_w)//2, box_x + box_w, box_y + (box_h + r_w)//2], fill=ribbon_color)
# Bow
bow_w = 20
draw.ellipse([72 - bow_w, box_y - 15, 72, box_y], fill="white")
draw.ellipse([72, box_y - 15, 72 + bow_w, box_y], fill="white")
save_logo(img, "birthday-greetings.png")
# 3. Monthly Revenue Report (Green, Chart)
def create_revenue_logo():
img, draw = create_base_logo("#10b981") # Green
# Bar Chart
margin_bottom = 110
bar_w = 20
gap = 10
start_x = (144 - (3 * bar_w + 2 * gap)) // 2
# Bars
heights = [30, 50, 75]
for i, h in enumerate(heights):
x = start_x + i * (bar_w + gap)
draw.rectangle([x, margin_bottom - h, x + bar_w, margin_bottom], fill="white")
# Trend Arrow (Rising)
arrow_start = (start_x - 5, margin_bottom - 10)
arrow_end = (start_x + 3 * bar_w + 2 * gap + 5, margin_bottom - 90)
# Simple line for trend
# draw.line([arrow_start, arrow_end], fill="white", width=4)
save_logo(img, "monthly-revenue-report.png")
# 4. Appointment Reminder (24hr) (Amber, Bell)
def create_reminder_logo():
img, draw = create_base_logo("#f59e0b") # Amber
cx, cy = 72, 70
r = 30
# Bell Dome
draw.chord([cx - r, cy - r, cx + r, cy + r], 180, 0, fill="white")
draw.rectangle([cx - r, cy, cx + r, cy + 20], fill="white")
# Bell Flare
draw.polygon([(cx - r, cy + 20), (cx + r, cy + 20), (cx + r + 5, cy + 30), (cx - r - 5, cy + 30)], fill="white")
# Clapper
draw.ellipse([cx - 8, cy + 28, cx + 8, cy + 42], fill="white")
# Notification Dot
draw.ellipse([cx + 15, cy - 25, cx + 35, cy - 5], fill="#dc2626")
save_logo(img, "appointment-reminder-24hr.png")
# 5. Inactive Customer Re-engagement (Purple, Heart)
def create_inactive_logo():
img, draw = create_base_logo("#8b5cf6") # Purple
hx, hy = 72, 75
size = 18
# Heart Shape
# Left circle
draw.ellipse([hx - size * 2, hy - size, hx, hy + size], fill="white")
# Right circle
draw.ellipse([hx, hy - size, hx + size * 2, hy + size], fill="white")
# Bottom triangle
draw.polygon([(hx - size * 2 + 1, hy + 4), (hx + size * 2 - 1, hy + 4), (hx, hy + size * 2 + 8)], fill="white")
# Refresh/Loop Arrow (Subtle arc above)
bbox = [25, 25, 119, 119]
draw.arc(bbox, start=180, end=0, fill="#ddd6fe", width=5)
draw.polygon([(119, 72), (110, 60), (128, 60)], fill="#ddd6fe")
save_logo(img, "inactive-customer-reengagement.png")
if __name__ == "__main__":
create_no_show_logo()
create_birthday_logo()
create_revenue_logo()
create_reminder_logo()
create_inactive_logo()

View File

@@ -1,39 +0,0 @@
#!/usr/bin/env python3
from google import genai
from google.genai import types
from PIL import Image
from io import BytesIO
# Setup Client
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
# Test with a simple prompt
prompt = "Create a simple modern app icon with a blue background and white envelope symbol, square format, flat design, minimalist"
print(f"Testing gemini-2.0-flash-exp...")
print(f"Prompt: '{prompt}'...")
try:
response = client.models.generate_content(
model='gemini-2.0-flash-exp',
contents=prompt,
config=types.GenerateContentConfig(
response_modalities=["IMAGE"]
)
)
# Save the Image
if response.candidates[0].content.parts:
for part in response.candidates[0].content.parts:
if part.inline_data:
image_data = part.inline_data.data
image = Image.open(BytesIO(image_data))
image.save("test_2_0_flash.png")
print("✓ Success! Saved test_2_0_flash.png")
else:
print(f"✗ Model returned text: {part.text[:100]}")
else:
print("✗ No content parts in response")
except Exception as e:
print(f"✗ Error: {type(e).__name__}: {str(e)[:300]}")

View File

@@ -1,194 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import requests
import base64
from pathlib import Path
# Gemini API configuration
API_KEY = "AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw"
API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent"
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
# Plugin configurations
plugins = [
{
'filename': 'daily-appointment-summary.png',
'prompt': '''Create a modern, professional icon/logo for a plugin called "Daily Appointment Summary Email".
Design requirements:
- Square icon, 512x512 pixels
- Flat design style with a slight gradient
- Primary color: Indigo/blue (#4f46e5)
- Icon should combine email and calendar/scheduling concepts
- Clean, minimalist design suitable for a SaaS application
- White or light elements on the colored background
- Rounded corners (similar to modern app icons)
- Professional and trustworthy appearance
The icon represents a plugin that sends daily email summaries of appointments to staff members.'''
},
{
'filename': 'no-show-tracker.png',
'prompt': '''Create a modern, professional icon/logo for a plugin called "No-Show Customer Tracker".
Design requirements:
- Square icon, 512x512 pixels
- Flat design style with a slight gradient
- Primary color: Red (#dc2626)
- Icon should represent missed appointments or absent customers
- Could show a person silhouette with an X, slash, or cancel symbol
- Clean, minimalist design suitable for a SaaS application
- White or light elements on the colored background
- Rounded corners (similar to modern app icons)
- Professional appearance
The icon represents a plugin that tracks customers who miss their appointments.'''
},
{
'filename': 'birthday-greetings.png',
'prompt': '''Create a modern, professional icon/logo for a plugin called "Birthday Greeting Campaign".
Design requirements:
- Square icon, 512x512 pixels
- Flat design style with a slight gradient
- Primary color: Pink (#ec4899)
- Icon should represent birthdays and celebrations
- Could show a birthday cake, gift, party hat, or balloon
- Clean, minimalist design suitable for a SaaS application
- White or light elements on the colored background
- Rounded corners (similar to modern app icons)
- Friendly and celebratory appearance
The icon represents a plugin that sends birthday emails with special offers to customers.'''
},
{
'filename': 'monthly-revenue-report.png',
'prompt': '''Create a modern, professional icon/logo for a plugin called "Monthly Revenue Report".
Design requirements:
- Square icon, 512x512 pixels
- Flat design style with a slight gradient
- Primary color: Green (#10b981)
- Icon should represent business growth, analytics, and financial reporting
- Could show an upward trending chart, graph, or money symbol
- Clean, minimalist design suitable for a SaaS application
- White or light elements on the colored background
- Rounded corners (similar to modern app icons)
- Professional and successful appearance
The icon represents a plugin that generates comprehensive monthly business statistics and revenue reports.'''
},
{
'filename': 'appointment-reminder-24hr.png',
'prompt': '''Create a modern, professional icon/logo for a plugin called "Appointment Reminder (24hr)".
Design requirements:
- Square icon, 512x512 pixels
- Flat design style with a slight gradient
- Primary color: Amber/Orange (#f59e0b)
- Icon should represent notifications, alerts, and reminders
- Could show a bell with a notification badge, clock, or alarm
- Clean, minimalist design suitable for a SaaS application
- White or light elements on the colored background
- Rounded corners (similar to modern app icons)
- Attention-grabbing but professional appearance
The icon represents a plugin that sends reminder emails to customers 24 hours before their appointments.'''
},
{
'filename': 'inactive-customer-reengagement.png',
'prompt': '''Create a modern, professional icon/logo for a plugin called "Inactive Customer Re-engagement".
Design requirements:
- Square icon, 512x512 pixels
- Flat design style with a slight gradient
- Primary color: Purple (#8b5cf6)
- Icon should represent customer retention, returning customers, or re-engagement
- Could show a heart, person with return arrow, refresh symbol, or comeback concept
- Clean, minimalist design suitable for a SaaS application
- White or light elements on the colored background
- Rounded corners (similar to modern app icons)
- Warm and welcoming appearance
The icon represents a plugin that wins back customers who haven't booked appointments recently.'''
}
]
def generate_image(prompt, filename):
"""Generate an image using Gemini API"""
print(f"\nGenerating {filename}...")
headers = {
"Content-Type": "application/json"
}
payload = {
"contents": [{
"parts": [{
"text": prompt
}]
}],
"generationConfig": {
"temperature": 1,
"topK": 40,
"topP": 0.95,
"maxOutputTokens": 8192,
}
}
try:
response = requests.post(
f"{API_URL}?key={API_KEY}",
headers=headers,
json=payload,
timeout=120
)
if response.status_code == 200:
result = response.json()
# Check if there's an image in the response
if 'candidates' in result and len(result['candidates']) > 0:
candidate = result['candidates'][0]
if 'content' in candidate and 'parts' in candidate['content']:
for part in candidate['content']['parts']:
if 'inlineData' in part:
# Extract and save the image
image_data = base64.b64decode(part['inlineData']['data'])
output_path = os.path.join(OUTPUT_DIR, filename)
with open(output_path, 'wb') as f:
f.write(image_data)
print(f"✓ Saved: {output_path}")
return True
elif 'text' in part:
print(f"Response: {part['text'][:200]}...")
print(f"✗ No image generated. Response: {result}")
return False
else:
print(f"✗ API Error ({response.status_code}): {response.text}")
return False
except Exception as e:
print(f"✗ Error: {str(e)}")
return False
def main():
# Create output directory
os.makedirs(OUTPUT_DIR, exist_ok=True)
print("Starting logo generation with Gemini API...")
print("=" * 60)
success_count = 0
for plugin in plugins:
if generate_image(plugin['prompt'], plugin['filename']):
success_count += 1
print("\n" + "=" * 60)
print(f"Generation complete: {success_count}/{len(plugins)} successful")
if __name__ == "__main__":
main()

View File

@@ -1,112 +0,0 @@
#!/usr/bin/env python3
import google.generativeai as genai
import os
from PIL import Image
import io
# Configure API Key
genai.configure(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
# Plugin configurations
plugins = [
{
'filename': 'daily-appointment-summary.png',
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
Design: Indigo blue gradient background (#4f46e5). White simple envelope icon combined with a small calendar symbol.
Style: Flat design, clean geometric shapes, professional SaaS application aesthetic.
The icon should be instantly recognizable at 48x48 pixels.'''
},
{
'filename': 'no-show-tracker.png',
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
Design: Red gradient background (#dc2626). White simple person silhouette with a bold X or cancel symbol overlay.
Style: Flat design, clean geometric shapes, professional SaaS application aesthetic.
The icon should clearly convey "missed appointment" at small sizes.'''
},
{
'filename': 'birthday-greetings.png',
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
Design: Pink gradient background (#ec4899). White simple birthday cake with candles or gift box with bow.
Style: Flat design, clean geometric shapes, cheerful yet professional aesthetic.
The icon should feel celebratory and friendly at small sizes.'''
},
{
'filename': 'monthly-revenue-report.png',
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
Design: Green gradient background (#10b981). White simple upward trending line chart or bar graph showing growth.
Style: Flat design, clean geometric shapes, professional business analytics aesthetic.
The icon should convey success and growth at small sizes.'''
},
{
'filename': 'appointment-reminder-24hr.png',
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
Design: Amber/orange gradient background (#f59e0b). White simple notification bell with a small red alert dot or badge.
Style: Flat design, clean geometric shapes, attention-grabbing yet professional aesthetic.
The icon should clearly indicate alerts and reminders at small sizes.'''
},
{
'filename': 'inactive-customer-reengagement.png',
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
Design: Purple gradient background (#8b5cf6). White simple heart symbol with a circular refresh/return arrow around it.
Style: Flat design, clean geometric shapes, warm and welcoming professional aesthetic.
The icon should convey customer care and comeback at small sizes.'''
}
]
def generate_image(prompt, filename):
"""Generate an image using Gemini 2.0 Flash Image Generation"""
print(f"\nGenerating {filename}...")
try:
# Use the image generation model
model = genai.GenerativeModel('gemini-2.0-flash-exp-image-generation')
# Generate the image
response = model.generate_content(prompt)
# Check for generated image
if hasattr(response, 'parts') and response.parts:
for part in response.parts:
if hasattr(part, 'inline_data') and part.inline_data:
# Extract image data
image_data = part.inline_data.data
# Save the image
output_path = os.path.join(OUTPUT_DIR, filename)
with open(output_path, 'wb') as f:
f.write(image_data)
print(f"✓ Saved: {output_path} ({len(image_data)} bytes)")
return True
# If no image data found, check if there's text response
if hasattr(response, 'text'):
print(f"✗ No image generated. Response text: {response.text[:200]}")
else:
print(f"✗ No image generated. Response: {response}")
return False
except Exception as e:
print(f"✗ Error: {type(e).__name__}: {str(e)}")
return False
def main():
# Create output directory
os.makedirs(OUTPUT_DIR, exist_ok=True)
print("Starting logo generation with Gemini 2.0 Flash Image Generation...")
print("=" * 60)
success_count = 0
for plugin in plugins:
if generate_image(plugin['prompt'], plugin['filename']):
success_count += 1
print("\n" + "=" * 60)
print(f"Generation complete: {success_count}/{len(plugins)} successful")
if __name__ == "__main__":
main()

View File

@@ -1,146 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import requests
import base64
import time
from pathlib import Path
# Imagen API configuration
API_KEY = "AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw"
MODEL = "imagen-4.0-generate-preview-06-06"
API_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:predict"
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
# Plugin configurations
plugins = [
{
'filename': 'daily-appointment-summary.png',
'prompt': '''A modern, minimalist app icon for "Daily Appointment Summary Email" plugin.
Square format with rounded corners. Indigo blue gradient background (#4f46e5).
White icon showing a combination of an envelope and a calendar.
Flat design, clean lines, professional SaaS application style.
The icon should be simple and recognizable at small sizes.'''
},
{
'filename': 'no-show-tracker.png',
'prompt': '''A modern, minimalist app icon for "No-Show Customer Tracker" plugin.
Square format with rounded corners. Red gradient background (#dc2626).
White icon showing a person silhouette with an X or cancel symbol overlay.
Flat design, clean lines, professional SaaS application style.
The icon should convey missed appointments clearly at small sizes.'''
},
{
'filename': 'birthday-greetings.png',
'prompt': '''A modern, minimalist app icon for "Birthday Greeting Campaign" plugin.
Square format with rounded corners. Pink gradient background (#ec4899).
White icon showing a birthday cake or gift box with a bow.
Flat design, clean lines, cheerful yet professional style.
The icon should be celebratory and friendly at small sizes.'''
},
{
'filename': 'monthly-revenue-report.png',
'prompt': '''A modern, minimalist app icon for "Monthly Revenue Report" plugin.
Square format with rounded corners. Green gradient background (#10b981).
White icon showing an upward trending chart or bar graph.
Flat design, clean lines, professional business analytics style.
The icon should convey growth and success at small sizes.'''
},
{
'filename': 'appointment-reminder-24hr.png',
'prompt': '''A modern, minimalist app icon for "Appointment Reminder" plugin.
Square format with rounded corners. Amber/orange gradient background (#f59e0b).
White icon showing a notification bell with a small red badge or alert dot.
Flat design, clean lines, attention-grabbing yet professional style.
The icon should convey alerts and reminders clearly at small sizes.'''
},
{
'filename': 'inactive-customer-reengagement.png',
'prompt': '''A modern, minimalist app icon for "Inactive Customer Re-engagement" plugin.
Square format with rounded corners. Purple gradient background (#8b5cf6).
White icon showing a heart with a circular refresh arrow around it.
Flat design, clean lines, warm and welcoming professional style.
The icon should convey customer care and return at small sizes.'''
}
]
def generate_image(prompt, filename):
"""Generate an image using Imagen API"""
print(f"\nGenerating {filename}...")
headers = {
"Content-Type": "application/json"
}
payload = {
"instances": [{
"prompt": prompt
}],
"parameters": {
"sampleCount": 1
}
}
try:
response = requests.post(
f"{API_URL}?key={API_KEY}",
headers=headers,
json=payload,
timeout=120
)
if response.status_code == 200:
result = response.json()
# Check if there's an image in the predictions
if 'predictions' in result and len(result['predictions']) > 0:
prediction = result['predictions'][0]
if 'bytesBase64Encoded' in prediction:
# Extract and save the image
image_data = base64.b64decode(prediction['bytesBase64Encoded'])
output_path = os.path.join(OUTPUT_DIR, filename)
with open(output_path, 'wb') as f:
f.write(image_data)
print(f"✓ Saved: {output_path} ({len(image_data)} bytes)")
return True
elif 'image' in prediction and 'bytesBase64Encoded' in prediction['image']:
image_data = base64.b64decode(prediction['image']['bytesBase64Encoded'])
output_path = os.path.join(OUTPUT_DIR, filename)
with open(output_path, 'wb') as f:
f.write(image_data)
print(f"✓ Saved: {output_path} ({len(image_data)} bytes)")
return True
print(f"✗ No image generated. Response: {str(result)[:300]}...")
return False
else:
print(f"✗ API Error ({response.status_code}): {response.text[:500]}")
return False
except Exception as e:
print(f"✗ Error: {str(e)}")
return False
def main():
# Create output directory
os.makedirs(OUTPUT_DIR, exist_ok=True)
print("Starting logo generation with Imagen 4.0 API...")
print("=" * 60)
success_count = 0
for i, plugin in enumerate(plugins):
if i > 0:
# Add delay between requests to avoid rate limiting
print("Waiting 2 seconds before next request...")
time.sleep(2)
if generate_image(plugin['prompt'], plugin['filename']):
success_count += 1
print("\n" + "=" * 60)
print(f"Generation complete: {success_count}/{len(plugins)} successful")
if __name__ == "__main__":
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 706 B

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -1,43 +0,0 @@
import google.generativeai as genai
import os
from PIL import Image
import io
# Configure API Key
genai.configure(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
# Try to initialize the model
try:
generation_model = genai.GenerativeModel('gemini-pro-vision')
print("Model initialized successfully")
# Define prompt
prompt = "A modern app icon with a blue background and white envelope symbol"
print(f"Generating image for: '{prompt}'...")
# Try to generate
response = generation_model.generate_content(prompt)
print(f"Response type: {type(response)}")
print(f"Response: {response}")
if hasattr(response, 'parts') and response.parts:
print(f"Parts: {response.parts}")
if hasattr(response.parts[0], 'inline_data'):
print("Has inline_data!")
image_data = response.parts[0].inline_data.data
image = Image.open(io.BytesIO(image_data))
image.save("test_output.png")
print("Success! Image saved to test_output.png")
else:
print("No inline_data attribute")
print(f"Part attributes: {dir(response.parts[0])}")
else:
print("No parts in response or response.parts doesn't exist")
if hasattr(response, 'text'):
print(f"Response text: {response.text}")
except Exception as e:
print(f"Error: {type(e).__name__}: {str(e)}")
import traceback
traceback.print_exc()

View File

@@ -1,41 +0,0 @@
from google import genai
from google.genai import types
from PIL import Image
from io import BytesIO
import os
# Setup Client
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
# Define the Prompt
prompt = "Create a simple modern app icon with a blue background and white envelope symbol, square format, flat design, minimalist"
print(f"Asking Gemini to generate: '{prompt}'...")
# Call the Gemini Model
try:
response = client.models.generate_content(
model='gemini-2.5-flash-image',
contents=prompt,
config=types.GenerateContentConfig(
response_modalities=["IMAGE"] # Tell Gemini to draw, not talk
)
)
# Save the Image
if response.candidates[0].content.parts:
for part in response.candidates[0].content.parts:
if part.inline_data:
image_data = part.inline_data.data
image = Image.open(BytesIO(image_data))
image.save("test_gemini_native.png")
print("Success! Saved test_gemini_native.png")
else:
print("Model returned text instead of image:", part.text)
else:
print("No content parts in response")
except Exception as e:
print(f"Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()

View File

@@ -1,41 +0,0 @@
from google import genai
from google.genai import types
from PIL import Image
from io import BytesIO
import os
# Initialize the Client
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
# Define the prompt
prompt = "A simple modern app icon with a blue background and white envelope symbol, square format, flat design"
print(f"Generating image for: '{prompt}'...")
# Call the API
try:
response = client.models.generate_images(
model='gemini-2.5-flash-image',
prompt=prompt,
config=types.GenerateImagesConfig(
number_of_images=1,
aspect_ratio="1:1",
safety_filter_level="BLOCK_LOW_AND_ABOVE",
person_generation="ALLOW_ADULT"
)
)
# Handle the response
for i, generated_image in enumerate(response.generated_images):
# Convert raw bytes to an image
image = Image.open(BytesIO(generated_image.image.image_bytes))
# Save to disk
filename = f"test_generated_image_{i}.png"
image.save(filename)
print(f"Success! Saved {filename}")
except Exception as e:
print(f"Error occurred: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()

View File

@@ -13,6 +13,7 @@ import { setCookie } from './utils/cookies';
// Import Login Page
import LoginPage from './pages/LoginPage';
import MFAVerifyPage from './pages/MFAVerifyPage';
import OAuthCallback from './pages/OAuthCallback';
// Import layouts
@@ -66,6 +67,7 @@ import PlatformSupport from './pages/PlatformSupport'; // Import Platform Suppor
import PluginMarketplace from './pages/PluginMarketplace'; // Import Plugin Marketplace page
import MyPlugins from './pages/MyPlugins'; // Import My Plugins page
import Tasks from './pages/Tasks'; // Import Tasks page for scheduled plugin executions
import EmailTemplates from './pages/EmailTemplates'; // Import Email Templates page
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
const queryClient = new QueryClient({
@@ -127,14 +129,22 @@ const AppContent: React.FC = () => {
const { data: user, isLoading: userLoading, error: userError } = useCurrentUser();
const { data: business, isLoading: businessLoading, error: businessError } = useCurrentBusiness();
const [darkMode, setDarkMode] = useState(false);
const [darkMode, setDarkMode] = useState(() => {
// Check localStorage first, then system preference
const saved = localStorage.getItem('darkMode');
if (saved !== null) {
return JSON.parse(saved);
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
const updateBusinessMutation = useUpdateBusiness();
const masqueradeMutation = useMasquerade();
const logoutMutation = useLogout();
// Apply dark mode class
// Apply dark mode class and persist to localStorage
React.useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode);
localStorage.setItem('darkMode', JSON.stringify(darkMode));
}, [darkMode]);
// Handle tokens in URL (from login or masquerade redirect)
@@ -222,6 +232,7 @@ const AppContent: React.FC = () => {
<Route path="/signup" element={<SignupPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
@@ -236,6 +247,7 @@ const AppContent: React.FC = () => {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
@@ -547,6 +559,16 @@ const AppContent: React.FC = () => {
)
}
/>
<Route
path="/email-templates"
element={
hasAccess(['owner', 'manager']) ? (
<EmailTemplates />
) : (
<Navigate to="/" />
)
}
/>
<Route path="/support" element={<PlatformSupport />} />
<Route
path="/customers"

View File

@@ -20,9 +20,10 @@ export interface MasqueradeStackEntry {
}
export interface LoginResponse {
access: string;
refresh: string;
user: {
// Regular login success
access?: string;
refresh?: string;
user?: {
id: number;
username: string;
email: string;
@@ -37,6 +38,11 @@ export interface LoginResponse {
business_subdomain?: string;
};
masquerade_stack?: MasqueradeStackEntry[];
// MFA challenge response
mfa_required?: boolean;
user_id?: number;
mfa_methods?: ('SMS' | 'TOTP' | 'BACKUP')[];
phone_last_4?: string;
}
export interface User {

233
frontend/src/api/mfa.ts Normal file
View File

@@ -0,0 +1,233 @@
/**
* MFA (Two-Factor Authentication) API
*/
import apiClient from './client';
// ============================================================================
// Types
// ============================================================================
export interface MFAStatus {
mfa_enabled: boolean;
mfa_method: 'NONE' | 'SMS' | 'TOTP' | 'BOTH';
methods: ('SMS' | 'TOTP' | 'BACKUP')[];
phone_last_4: string | null;
phone_verified: boolean;
totp_verified: boolean;
backup_codes_count: number;
backup_codes_generated_at: string | null;
trusted_devices_count: number;
}
export interface TOTPSetupResponse {
success: boolean;
secret: string;
qr_code: string; // Data URL for QR code image
provisioning_uri: string;
message: string;
}
export interface MFAEnableResponse {
success: boolean;
message: string;
mfa_method: string;
backup_codes?: string[];
backup_codes_message?: string;
}
export interface BackupCodesResponse {
success: boolean;
backup_codes: string[];
message: string;
warning: string;
}
export interface BackupCodesStatus {
count: number;
generated_at: string | null;
}
export interface TrustedDevice {
id: number;
name: string;
ip_address: string;
created_at: string;
last_used_at: string;
expires_at: string;
is_current: boolean;
}
export interface MFALoginResponse {
mfa_required: boolean;
user_id?: number;
mfa_methods?: string[];
phone_last_4?: string;
}
export interface MFAVerifyResponse {
success: boolean;
access: string;
refresh: string;
user: {
id: number;
email: string;
username: string;
first_name: string;
last_name: string;
full_name: string;
role: string;
business_subdomain: string | null;
mfa_enabled: boolean;
};
}
// ============================================================================
// MFA Status
// ============================================================================
/**
* Get current MFA status
*/
export const getMFAStatus = async (): Promise<MFAStatus> => {
const response = await apiClient.get<MFAStatus>('/api/auth/mfa/status/');
return response.data;
};
// ============================================================================
// SMS Setup
// ============================================================================
/**
* Send phone verification code
*/
export const sendPhoneVerification = async (phone: string): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/api/auth/mfa/phone/send/', { phone });
return response.data;
};
/**
* Verify phone number with code
*/
export const verifyPhone = async (code: string): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/api/auth/mfa/phone/verify/', { code });
return response.data;
};
/**
* Enable SMS MFA (requires verified phone)
*/
export const enableSMSMFA = async (): Promise<MFAEnableResponse> => {
const response = await apiClient.post<MFAEnableResponse>('/api/auth/mfa/sms/enable/');
return response.data;
};
// ============================================================================
// TOTP Setup (Authenticator App)
// ============================================================================
/**
* Initialize TOTP setup (returns QR code and secret)
*/
export const setupTOTP = async (): Promise<TOTPSetupResponse> => {
const response = await apiClient.post<TOTPSetupResponse>('/api/auth/mfa/totp/setup/');
return response.data;
};
/**
* Verify TOTP code to complete setup
*/
export const verifyTOTPSetup = async (code: string): Promise<MFAEnableResponse> => {
const response = await apiClient.post<MFAEnableResponse>('/api/auth/mfa/totp/verify/', { code });
return response.data;
};
// ============================================================================
// Backup Codes
// ============================================================================
/**
* Generate new backup codes (invalidates old ones)
*/
export const generateBackupCodes = async (): Promise<BackupCodesResponse> => {
const response = await apiClient.post<BackupCodesResponse>('/api/auth/mfa/backup-codes/');
return response.data;
};
/**
* Get backup codes status
*/
export const getBackupCodesStatus = async (): Promise<BackupCodesStatus> => {
const response = await apiClient.get<BackupCodesStatus>('/api/auth/mfa/backup-codes/status/');
return response.data;
};
// ============================================================================
// Disable MFA
// ============================================================================
/**
* Disable MFA (requires password or valid MFA code)
*/
export const disableMFA = async (credentials: { password?: string; mfa_code?: string }): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/api/auth/mfa/disable/', credentials);
return response.data;
};
// ============================================================================
// MFA Login Challenge
// ============================================================================
/**
* Send MFA code for login (SMS only)
*/
export const sendMFALoginCode = async (userId: number, method: 'SMS' | 'TOTP' = 'SMS'): Promise<{ success: boolean; message: string; method: string }> => {
const response = await apiClient.post('/api/auth/mfa/login/send/', { user_id: userId, method });
return response.data;
};
/**
* Verify MFA code to complete login
*/
export const verifyMFALogin = async (
userId: number,
code: string,
method: 'SMS' | 'TOTP' | 'BACKUP',
trustDevice: boolean = false
): Promise<MFAVerifyResponse> => {
const response = await apiClient.post<MFAVerifyResponse>('/api/auth/mfa/login/verify/', {
user_id: userId,
code,
method,
trust_device: trustDevice,
});
return response.data;
};
// ============================================================================
// Trusted Devices
// ============================================================================
/**
* List trusted devices
*/
export const listTrustedDevices = async (): Promise<{ devices: TrustedDevice[] }> => {
const response = await apiClient.get('/api/auth/mfa/devices/');
return response.data;
};
/**
* Revoke a specific trusted device
*/
export const revokeTrustedDevice = async (deviceId: number): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/api/auth/mfa/devices/${deviceId}/`);
return response.data;
};
/**
* Revoke all trusted devices
*/
export const revokeAllTrustedDevices = async (): Promise<{ success: boolean; message: string; count: number }> => {
const response = await apiClient.delete('/api/auth/mfa/devices/revoke-all/');
return response.data;
};

View File

@@ -121,29 +121,34 @@ export const changePassword = async (
});
};
// 2FA API
// 2FA API (using new MFA endpoints)
export const setupTOTP = async (): Promise<TOTPSetupResponse> => {
const response = await apiClient.post('/api/auth/2fa/totp/setup/');
const response = await apiClient.post('/api/auth/mfa/totp/setup/');
return response.data;
};
export const verifyTOTP = async (code: string): Promise<TOTPVerifyResponse> => {
const response = await apiClient.post('/api/auth/2fa/totp/verify/', { code });
return response.data;
const response = await apiClient.post('/api/auth/mfa/totp/verify/', { code });
// Map response to expected format
return {
success: response.data.success,
recovery_codes: response.data.backup_codes || [],
};
};
export const disableTOTP = async (code: string): Promise<void> => {
await apiClient.post('/api/auth/2fa/totp/disable/', { code });
await apiClient.post('/api/auth/mfa/disable/', { mfa_code: code });
};
export const getRecoveryCodes = async (): Promise<string[]> => {
const response = await apiClient.get('/api/auth/2fa/recovery-codes/');
return response.data.codes;
const response = await apiClient.get('/api/auth/mfa/backup-codes/status/');
// Note: Actual codes are only shown when generated, not retrievable later
return [];
};
export const regenerateRecoveryCodes = async (): Promise<string[]> => {
const response = await apiClient.post('/api/auth/2fa/recovery-codes/regenerate/');
return response.data.codes;
const response = await apiClient.post('/api/auth/mfa/backup-codes/');
return response.data.backup_codes;
};
// Sessions API

View File

@@ -0,0 +1,169 @@
/**
* API client for ticket email settings
*/
import apiClient from './client';
export interface TicketEmailSettings {
// IMAP settings (inbound)
imap_host: string;
imap_port: number;
imap_use_ssl: boolean;
imap_username: string;
imap_password_masked: string;
imap_folder: string;
// SMTP settings (outbound)
smtp_host: string;
smtp_port: number;
smtp_use_tls: boolean;
smtp_use_ssl: boolean;
smtp_username: string;
smtp_password_masked: string;
smtp_from_email: string;
smtp_from_name: string;
// General settings
support_email_address: string;
support_email_domain: string;
is_enabled: boolean;
delete_after_processing: boolean;
check_interval_seconds: number;
max_attachment_size_mb: number;
allowed_attachment_types: string[];
// Status
last_check_at: string | null;
last_error: string;
emails_processed_count: number;
is_configured: boolean;
is_imap_configured: boolean;
is_smtp_configured: boolean;
created_at: string;
updated_at: string;
}
export interface TicketEmailSettingsUpdate {
// IMAP settings
imap_host?: string;
imap_port?: number;
imap_use_ssl?: boolean;
imap_username?: string;
imap_password?: string;
imap_folder?: string;
// SMTP settings
smtp_host?: string;
smtp_port?: number;
smtp_use_tls?: boolean;
smtp_use_ssl?: boolean;
smtp_username?: string;
smtp_password?: string;
smtp_from_email?: string;
smtp_from_name?: string;
// General settings
support_email_address?: string;
support_email_domain?: string;
is_enabled?: boolean;
delete_after_processing?: boolean;
check_interval_seconds?: number;
max_attachment_size_mb?: number;
allowed_attachment_types?: string[];
}
export interface TestConnectionResult {
success: boolean;
message: string;
}
export interface FetchNowResult {
success: boolean;
message: string;
processed: number;
}
export interface IncomingTicketEmail {
id: number;
message_id: string;
from_address: string;
from_name: string;
to_address: string;
subject: string;
body_text: string;
extracted_reply: string;
ticket: number | null;
ticket_subject: string;
matched_user: number | null;
ticket_id_from_email: string;
processing_status: 'PENDING' | 'PROCESSED' | 'FAILED' | 'SPAM' | 'NO_MATCH' | 'DUPLICATE';
processing_status_display: string;
error_message: string;
email_date: string;
received_at: string;
processed_at: string | null;
}
/**
* Get ticket email settings
*/
export const getTicketEmailSettings = async (): Promise<TicketEmailSettings> => {
const response = await apiClient.get('/api/tickets/email-settings/');
return response.data;
};
/**
* Update ticket email settings
*/
export const updateTicketEmailSettings = async (
data: TicketEmailSettingsUpdate
): Promise<TicketEmailSettings> => {
const response = await apiClient.patch('/api/tickets/email-settings/', data);
return response.data;
};
/**
* Test IMAP connection
*/
export const testImapConnection = async (): Promise<TestConnectionResult> => {
const response = await apiClient.post('/api/tickets/email-settings/test-imap/');
return response.data;
};
/**
* Test SMTP connection
*/
export const testSmtpConnection = async (): Promise<TestConnectionResult> => {
const response = await apiClient.post('/api/tickets/email-settings/test-smtp/');
return response.data;
};
// Legacy alias for backwards compatibility
export const testEmailConnection = testImapConnection;
/**
* Manually trigger email fetch
*/
export const fetchEmailsNow = async (): Promise<FetchNowResult> => {
const response = await apiClient.post('/api/tickets/email-settings/fetch-now/');
return response.data;
};
/**
* Get incoming email audit log
*/
export const getIncomingEmails = async (params?: {
status?: string;
ticket?: number;
}): Promise<IncomingTicketEmail[]> => {
const response = await apiClient.get('/api/tickets/incoming-emails/', { params });
return response.data;
};
/**
* Reprocess a failed incoming email
*/
export const reprocessIncomingEmail = async (id: number): Promise<{
success: boolean;
message: string;
comment_id?: number;
ticket_id?: number;
}> => {
const response = await apiClient.post(`/api/tickets/incoming-emails/${id}/reprocess/`);
return response.data;
};

View File

@@ -0,0 +1,456 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery } from '@tanstack/react-query';
import {
X,
Save,
Eye,
Code,
FileText,
Monitor,
Smartphone,
Plus,
AlertTriangle,
ChevronDown
} from 'lucide-react';
import api from '../api/client';
import { EmailTemplate, EmailTemplateCategory, EmailTemplateVariableGroup } from '../types';
interface EmailTemplateFormProps {
template?: EmailTemplate | null;
onClose: () => void;
onSuccess: () => void;
}
const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
template,
onClose,
onSuccess,
}) => {
const { t } = useTranslation();
const isEditing = !!template;
// Form state
const [name, setName] = useState(template?.name || '');
const [description, setDescription] = useState(template?.description || '');
const [subject, setSubject] = useState(template?.subject || '');
const [htmlContent, setHtmlContent] = useState(template?.htmlContent || '');
const [textContent, setTextContent] = useState(template?.textContent || '');
const [category, setCategory] = useState<EmailTemplateCategory>(template?.category || 'OTHER');
// UI state
const [activeTab, setActiveTab] = useState<'html' | 'text'>('html');
const [editorMode, setEditorMode] = useState<'visual' | 'code'>('code');
const [previewDevice, setPreviewDevice] = useState<'desktop' | 'mobile'>('desktop');
const [showPreview, setShowPreview] = useState(false);
const [showVariables, setShowVariables] = useState(false);
// Fetch available variables
const { data: variablesData } = useQuery<{ variables: EmailTemplateVariableGroup[] }>({
queryKey: ['email-template-variables'],
queryFn: async () => {
const { data } = await api.get('/api/email-templates/variables/');
return data;
},
});
// Preview mutation
const previewMutation = useMutation({
mutationFn: async () => {
const { data } = await api.post('/api/email-templates/preview/', {
subject,
html_content: htmlContent,
text_content: textContent,
});
return data;
},
});
// Create/Update mutation
const saveMutation = useMutation({
mutationFn: async () => {
const payload = {
name,
description,
subject,
html_content: htmlContent,
text_content: textContent,
category,
scope: 'BUSINESS', // Business users only create business templates
};
if (isEditing && template) {
const { data } = await api.patch(`/api/email-templates/${template.id}/`, payload);
return data;
} else {
const { data } = await api.post('/api/email-templates/', payload);
return data;
}
},
onSuccess: () => {
onSuccess();
},
});
const handlePreview = () => {
previewMutation.mutate();
setShowPreview(true);
};
const insertVariable = (code: string) => {
if (activeTab === 'html') {
setHtmlContent(prev => prev + code);
} else if (activeTab === 'text') {
setTextContent(prev => prev + code);
}
};
const categories: { value: EmailTemplateCategory; label: string }[] = [
{ value: 'APPOINTMENT', label: t('emailTemplates.categoryAppointment', 'Appointment') },
{ value: 'REMINDER', label: t('emailTemplates.categoryReminder', 'Reminder') },
{ value: 'CONFIRMATION', label: t('emailTemplates.categoryConfirmation', 'Confirmation') },
{ value: 'MARKETING', label: t('emailTemplates.categoryMarketing', 'Marketing') },
{ value: 'NOTIFICATION', label: t('emailTemplates.categoryNotification', 'Notification') },
{ value: 'REPORT', label: t('emailTemplates.categoryReport', 'Report') },
{ value: 'OTHER', label: t('emailTemplates.categoryOther', 'Other') },
];
const isValid = name.trim() && subject.trim() && (htmlContent.trim() || textContent.trim());
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-6xl w-full max-h-[95vh] overflow-hidden flex flex-col">
{/* Modal Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{isEditing
? t('emailTemplates.edit', 'Edit Template')
: t('emailTemplates.create', 'Create Template')}
</h3>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Modal Body */}
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - Form */}
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('emailTemplates.name', 'Template Name')} *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('emailTemplates.namePlaceholder', 'e.g., Appointment Confirmation')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('emailTemplates.category', 'Category')}
</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value as EmailTemplateCategory)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
{categories.map(cat => (
<option key={cat.value} value={cat.value}>{cat.label}</option>
))}
</select>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('emailTemplates.description', 'Description')}
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('emailTemplates.descriptionPlaceholder', 'Brief description of when this template is used')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
{/* Subject */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('emailTemplates.subject', 'Subject Line')} *
</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={t('emailTemplates.subjectPlaceholder', 'e.g., Your appointment is confirmed!')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
{/* Variables Dropdown */}
<div className="relative">
<button
type="button"
onClick={() => setShowVariables(!showVariables)}
className="flex items-center gap-2 text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
<Plus className="h-4 w-4" />
{t('emailTemplates.insertVariable', 'Insert Variable')}
<ChevronDown className={`h-4 w-4 transition-transform ${showVariables ? 'rotate-180' : ''}`} />
</button>
{showVariables && variablesData?.variables && (
<div className="absolute z-10 mt-2 w-80 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 max-h-64 overflow-y-auto">
{variablesData.variables.map((group) => (
<div key={group.category} className="mb-4 last:mb-0">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
{group.category}
</h4>
<div className="space-y-1">
{group.items.map((variable) => (
<button
key={variable.code}
type="button"
onClick={() => {
insertVariable(variable.code);
setShowVariables(false);
}}
className="w-full flex items-center justify-between px-2 py-1.5 text-sm rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-left"
>
<code className="text-brand-600 dark:text-brand-400 font-mono text-xs">
{variable.code}
</code>
<span className="text-gray-500 dark:text-gray-400 text-xs ml-2">
{variable.description}
</span>
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Content Tabs */}
<div>
<div className="flex items-center gap-4 mb-2">
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
<button
type="button"
onClick={() => setActiveTab('html')}
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 ${
activeTab === 'html'
? 'bg-brand-600 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
>
<Code className="h-4 w-4" />
HTML
</button>
<button
type="button"
onClick={() => setActiveTab('text')}
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 ${
activeTab === 'text'
? 'bg-brand-600 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
>
<FileText className="h-4 w-4" />
Text
</button>
</div>
{/* Editor Mode Toggle (for HTML only) */}
{activeTab === 'html' && (
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
<button
type="button"
onClick={() => setEditorMode('code')}
className={`px-3 py-1.5 text-xs font-medium ${
editorMode === 'code'
? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white'
: 'bg-white dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
Code
</button>
<button
type="button"
onClick={() => setEditorMode('visual')}
className={`px-3 py-1.5 text-xs font-medium ${
editorMode === 'visual'
? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white'
: 'bg-white dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
Visual
</button>
</div>
)}
</div>
{/* Content Editor */}
{activeTab === 'html' && (
<textarea
value={htmlContent}
onChange={(e) => setHtmlContent(e.target.value)}
rows={12}
placeholder={t('emailTemplates.htmlPlaceholder', '<html>\n <body>\n <p>Hello {{CUSTOMER_NAME}},</p>\n <p>Your appointment is confirmed!</p>\n </body>\n</html>')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 font-mono text-sm"
/>
)}
{activeTab === 'text' && (
<textarea
value={textContent}
onChange={(e) => setTextContent(e.target.value)}
rows={12}
placeholder={t('emailTemplates.textPlaceholder', 'Hello {{CUSTOMER_NAME}},\n\nYour appointment is confirmed!\n\nBest regards,\n{{BUSINESS_NAME}}')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 font-mono text-sm"
/>
)}
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{activeTab === 'html'
? t('emailTemplates.htmlHelp', 'Write HTML email content. Use variables like {{CUSTOMER_NAME}} for dynamic content.')
: t('emailTemplates.textHelp', 'Plain text fallback for email clients that don\'t support HTML.')}
</p>
</div>
</div>
{/* Right Column - Preview */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('emailTemplates.preview', 'Preview')}
</h4>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPreviewDevice('desktop')}
className={`p-2 rounded ${
previewDevice === 'desktop'
? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
title={t('emailTemplates.desktopPreview', 'Desktop preview')}
>
<Monitor className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setPreviewDevice('mobile')}
className={`p-2 rounded ${
previewDevice === 'mobile'
? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
title={t('emailTemplates.mobilePreview', 'Mobile preview')}
>
<Smartphone className="h-4 w-4" />
</button>
<button
type="button"
onClick={handlePreview}
disabled={previewMutation.isPending}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
>
<Eye className="h-4 w-4" />
{t('emailTemplates.refresh', 'Refresh')}
</button>
</div>
</div>
{/* Subject Preview */}
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg">
<span className="text-xs text-gray-500 dark:text-gray-400 block mb-1">
{t('emailTemplates.subject', 'Subject')}:
</span>
<span className="text-sm text-gray-900 dark:text-white font-medium">
{previewMutation.data?.subject || subject || 'No subject'}
</span>
</div>
{/* HTML Preview */}
<div
className={`border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white ${
previewDevice === 'mobile' ? 'max-w-[375px] mx-auto' : ''
}`}
>
{previewMutation.isPending ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-brand-600"></div>
</div>
) : (
<iframe
srcDoc={previewMutation.data?.html_content || htmlContent || '<p style="padding: 20px; color: #888;">No HTML content</p>'}
className="w-full h-80"
title="Email Preview"
sandbox="allow-same-origin"
/>
)}
</div>
{/* Footer Warning for Free Tier */}
{previewMutation.data?.force_footer && (
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-amber-800 dark:text-amber-200 font-medium">
{t('emailTemplates.footerWarning', 'Powered by SmoothSchedule footer')}
</p>
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
{t('emailTemplates.footerWarningDesc', 'Free tier accounts include a footer in all emails. Upgrade to remove it.')}
</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Modal Footer */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={() => saveMutation.mutate()}
disabled={!isValid || saveMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{saveMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('common.saving', 'Saving...')}
</>
) : (
<>
<Save className="h-4 w-4" />
{isEditing ? t('common.save', 'Save') : t('common.create', 'Create')}
</>
)}
</button>
</div>
</div>
</div>
);
};
export default EmailTemplateForm;

View File

@@ -0,0 +1,112 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Mail, ExternalLink } from 'lucide-react';
import api from '../api/client';
import { EmailTemplate } from '../types';
interface EmailTemplateSelectorProps {
value: string | number | undefined;
onChange: (templateId: string | number | undefined) => void;
category?: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
className?: string;
}
const EmailTemplateSelector: React.FC<EmailTemplateSelectorProps> = ({
value,
onChange,
category,
placeholder,
required = false,
disabled = false,
className = '',
}) => {
const { t } = useTranslation();
// Fetch email templates
const { data: templates = [], isLoading } = useQuery<EmailTemplate[]>({
queryKey: ['email-templates-list', category],
queryFn: async () => {
const params = new URLSearchParams();
if (category) params.append('category', category);
const { data } = await api.get(`/api/email-templates/?${params.toString()}`);
return data.map((t: any) => ({
id: String(t.id),
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updatedAt: t.updated_at,
}));
},
});
const selectedTemplate = templates.find(t => String(t.id) === String(value));
return (
<div className={`space-y-2 ${className}`}>
<div className="relative">
<select
value={value || ''}
onChange={(e) => onChange(e.target.value || undefined)}
disabled={disabled || isLoading}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed appearance-none"
>
<option value="">
{isLoading
? t('common.loading', 'Loading...')
: placeholder || t('emailTemplates.selectTemplate', 'Select a template...')}
</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
{template.category !== 'OTHER' && ` (${template.category})`}
</option>
))}
</select>
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* Selected template info */}
{selectedTemplate && (
<div className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded text-sm">
<span className="text-gray-600 dark:text-gray-400 truncate">
{selectedTemplate.description || selectedTemplate.name}
</span>
<a
href={`#/email-templates`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-brand-600 dark:text-brand-400 hover:underline ml-2 flex-shrink-0"
>
<ExternalLink className="h-3 w-3" />
{t('common.edit', 'Edit')}
</a>
</div>
)}
{/* Empty state with link to create */}
{!isLoading && templates.length === 0 && (
<div className="text-sm text-gray-500 dark:text-gray-400">
{t('emailTemplates.noTemplatesYet', 'No email templates yet.')}{' '}
<a
href="#/email-templates"
className="text-brand-600 dark:text-brand-400 hover:underline"
>
{t('emailTemplates.createFirst', 'Create your first template')}
</a>
</div>
)}
</div>
);
};
export default EmailTemplateSelector;

View File

@@ -22,7 +22,8 @@ import {
Plug,
Package,
Clock,
Store
Store,
Mail
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
@@ -226,6 +227,14 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
<Package size={16} className="shrink-0" />
<span>{t('nav.myPlugins', 'My Plugins')}</span>
</Link>
<Link
to="/email-templates"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/email-templates' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
title={t('nav.emailTemplates', 'Email Templates')}
>
<Mail size={16} className="shrink-0" />
<span>{t('nav.emailTemplates', 'Email Templates')}</span>
</Link>
<Link
to="/help/plugins"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/plugins' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, User, Send, MessageSquare, Clock, AlertCircle } from 'lucide-react';
import { X, User, Send, MessageSquare, Clock, AlertCircle, Mail } from 'lucide-react';
import { Ticket, TicketComment, TicketStatus, TicketPriority, TicketCategory, TicketType } from '../types';
import { useCreateTicket, useUpdateTicket, useTicketComments, useCreateTicketComment } from '../hooks/useTickets';
import { useStaffForAssignment } from '../hooks/useUsers';
import { useStaffForAssignment, usePlatformStaffForAssignment } from '../hooks/useUsers';
import { useQueryClient } from '@tanstack/react-query';
import { useSandbox } from '../contexts/SandboxContext';
import { useCurrentUser } from '../hooks/useAuth';
interface TicketModalProps {
ticket?: Ticket | null; // If provided, it's an edit/detail view
@@ -25,6 +26,7 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
const { t } = useTranslation();
const queryClient = useQueryClient();
const { isSandbox } = useSandbox();
const { data: currentUser } = useCurrentUser();
const [subject, setSubject] = useState(ticket?.subject || '');
const [description, setDescription] = useState(ticket?.description || '');
const [priority, setPriority] = useState<TicketPriority>(ticket?.priority || 'MEDIUM');
@@ -35,11 +37,19 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
const [replyText, setReplyText] = useState('');
const [internalNoteText, setInternalNoteText] = useState('');
// Check if user is a platform admin (superuser or platform_manager)
const isPlatformAdmin = currentUser?.role && ['superuser', 'platform_manager'].includes(currentUser.role);
const isPlatformStaff = currentUser?.role && ['superuser', 'platform_manager', 'platform_support'].includes(currentUser.role);
// Check if this is a platform ticket in sandbox mode (should be disabled)
const isPlatformTicketInSandbox = ticketType === 'PLATFORM' && isSandbox;
// Fetch users for assignee dropdown
const { data: users = [] } = useStaffForAssignment();
// Fetch users for assignee dropdown - use platform staff for platform tickets
const { data: businessUsers = [] } = useStaffForAssignment();
const { data: platformUsers = [] } = usePlatformStaffForAssignment();
// Use platform staff for PLATFORM tickets, business staff otherwise
const users = ticketType === 'PLATFORM' ? platformUsers : businessUsers;
// Fetch comments for the ticket if in detail/edit mode
const { data: comments, isLoading: isLoadingComments } = useTicketComments(ticket?.id);
@@ -217,8 +227,8 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
</div>
)}
{/* Priority & Category - Hide for platform tickets when viewing/creating */}
{ticketType !== 'PLATFORM' && (
{/* Priority & Category - Show for non-PLATFORM tickets OR platform admins viewing PLATFORM tickets */}
{(ticketType !== 'PLATFORM' || isPlatformAdmin) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@@ -229,7 +239,7 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
value={priority}
onChange={(e) => setPriority(e.target.value as TicketPriority)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending}
disabled={!!ticket && !isPlatformAdmin && !createTicketMutation.isPending && !updateTicketMutation.isPending}
>
{priorityOptions.map(opt => (
<option key={opt} value={opt}>{t(`tickets.priorities.${opt.toLowerCase()}`)}</option>
@@ -245,7 +255,7 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
value={category}
onChange={(e) => setCategory(e.target.value as TicketCategory)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending}
disabled={!!ticket && !isPlatformAdmin && !createTicketMutation.isPending && !updateTicketMutation.isPending}
>
{availableCategories.map(cat => (
<option key={cat} value={cat}>{t(`tickets.categories.${cat.toLowerCase()}`)}</option>
@@ -255,8 +265,19 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
</div>
)}
{/* Assignee & Status (only visible for existing non-PLATFORM tickets) */}
{ticket && ticketType !== 'PLATFORM' && (
{/* External Email Info - Show for platform tickets from external senders */}
{ticket && ticketType === 'PLATFORM' && isPlatformStaff && ticket.externalEmail && (
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-center gap-2 text-sm text-blue-800 dark:text-blue-200">
<Mail size={16} />
<span className="font-medium">{t('tickets.externalSender', 'External Sender')}:</span>
<span>{ticket.externalName ? `${ticket.externalName} <${ticket.externalEmail}>` : ticket.externalEmail}</span>
</div>
</div>
)}
{/* Assignee & Status - Show for existing tickets (non-PLATFORM OR platform admins viewing PLATFORM) */}
{ticket && (ticketType !== 'PLATFORM' || isPlatformAdmin) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="assignee" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@@ -314,7 +335,7 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
)}
</div>
)}
{ticket && ticketType !== 'PLATFORM' && ( // Show update button for existing non-PLATFORM tickets
{ticket && (ticketType !== 'PLATFORM' || isPlatformAdmin) && ( // Show update button for existing tickets (non-PLATFORM OR platform admins)
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="submit"
@@ -379,8 +400,8 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
</div>
</form>
{/* Internal Note Form - Only show for non-PLATFORM tickets */}
{ticketType !== 'PLATFORM' && (
{/* Internal Note Form - Show for non-PLATFORM tickets OR platform staff viewing PLATFORM tickets */}
{(ticketType !== 'PLATFORM' || isPlatformStaff) && (
<form onSubmit={handleAddInternalNote} className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
<label className="block text-sm font-medium text-orange-600 dark:text-orange-400">
{t('tickets.internalNoteLabel', 'Internal Note')}

View File

@@ -58,7 +58,15 @@ export const SandboxProvider: React.FC<SandboxProviderProps> = ({ children }) =>
export const useSandbox = (): SandboxContextType => {
const context = useContext(SandboxContext);
if (context === undefined) {
throw new Error('useSandbox must be used within a SandboxProvider');
// Return default values when used outside SandboxProvider
// This happens for platform admins who don't have sandbox mode
return {
isSandbox: false,
sandboxEnabled: false,
isLoading: false,
toggleSandbox: async () => {},
isToggling: false,
};
}
return context;
};

View File

@@ -0,0 +1,106 @@
/**
* React Query hooks for ticket email settings
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getTicketEmailSettings,
updateTicketEmailSettings,
testImapConnection,
testSmtpConnection,
fetchEmailsNow,
getIncomingEmails,
reprocessIncomingEmail,
TicketEmailSettings,
TicketEmailSettingsUpdate,
IncomingTicketEmail,
} from '../api/ticketEmailSettings';
const QUERY_KEY = 'ticketEmailSettings';
const INCOMING_EMAILS_KEY = 'incomingTicketEmails';
/**
* Hook to fetch ticket email settings
*/
export const useTicketEmailSettings = () => {
return useQuery<TicketEmailSettings>({
queryKey: [QUERY_KEY],
queryFn: getTicketEmailSettings,
});
};
/**
* Hook to update ticket email settings
*/
export const useUpdateTicketEmailSettings = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: TicketEmailSettingsUpdate) => updateTicketEmailSettings(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
};
/**
* Hook to test IMAP connection
*/
export const useTestImapConnection = () => {
return useMutation({
mutationFn: testImapConnection,
});
};
/**
* Hook to test SMTP connection
*/
export const useTestSmtpConnection = () => {
return useMutation({
mutationFn: testSmtpConnection,
});
};
// Legacy alias
export const useTestEmailConnection = useTestImapConnection;
/**
* Hook to manually fetch emails
*/
export const useFetchEmailsNow = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: fetchEmailsNow,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
queryClient.invalidateQueries({ queryKey: [INCOMING_EMAILS_KEY] });
},
});
};
/**
* Hook to fetch incoming email audit log
*/
export const useIncomingEmails = (params?: { status?: string; ticket?: number }) => {
return useQuery<IncomingTicketEmail[]>({
queryKey: [INCOMING_EMAILS_KEY, params],
queryFn: () => getIncomingEmails(params),
});
};
/**
* Hook to reprocess a failed incoming email
*/
export const useReprocessIncomingEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => reprocessIncomingEmail(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [INCOMING_EMAILS_KEY] });
},
});
};
export type { TicketEmailSettings, TicketEmailSettingsUpdate, IncomingTicketEmail };

View File

@@ -46,6 +46,29 @@ export const useStaffForAssignment = () => {
});
};
/**
* Hook to fetch platform staff members for ticket assignment.
* Returns platform admins (superuser, platform_manager, platform_support) formatted for dropdown use.
*/
export const usePlatformStaffForAssignment = () => {
return useQuery<{ id: string; name: string; email: string; role: string }[]>({
queryKey: ['platformStaffForAssignment'],
queryFn: async () => {
const response = await apiClient.get('/api/platform/users/');
// Filter to only platform-level roles and format for dropdown
const platformRoles = ['superuser', 'platform_manager', 'platform_support'];
return response.data
.filter((user: { role: string }) => platformRoles.includes(user.role))
.map((user: { id: number; name?: string; email: string; role: string }) => ({
id: String(user.id),
name: user.name || user.email,
email: user.email,
role: user.role,
}));
},
});
};
/**
* Hook to update a staff member's permissions
*/

View File

@@ -11,6 +11,7 @@ export const CURRENT_BUSINESS: Business = {
status: 'Active',
joinedAt: new Date('2023-01-15'),
resourcesCanReschedule: false,
paymentsEnabled: true,
requirePaymentMethodToBook: true,
cancellationWindowHours: 24,
lateCancellationFeePercent: 50,
@@ -89,22 +90,22 @@ const staffUserTech: User = { id: 'u_staff_tech', name: 'Jen IT', email: 'jen@te
export const RESOURCES: Resource[] = [
{ id: 'r1', name: 'Bay 1 (Lift)', type: 'ROOM' },
{ id: 'r2', name: 'Bay 2 (Lift)', type: 'ROOM' },
{ id: 'r3', name: 'Mike (Senior Mech)', type: 'STAFF', userId: staffUserAcme.id },
{ id: 'r4', name: 'Stacy Staff (Diag Tech)', type: 'STAFF', userId: STAFF_USER.id },
{ id: 'r5', name: 'Alignment Machine', type: 'EQUIPMENT' },
{ id: 'r6', name: 'Service Bay 3', type: 'ROOM', userId: RESOURCE_USER.id },
{ id: 'r1', name: 'Bay 1 (Lift)', type: 'ROOM', maxConcurrentEvents: 1 },
{ id: 'r2', name: 'Bay 2 (Lift)', type: 'ROOM', maxConcurrentEvents: 1 },
{ id: 'r3', name: 'Mike (Senior Mech)', type: 'STAFF', userId: String(staffUserAcme.id), maxConcurrentEvents: 1 },
{ id: 'r4', name: 'Stacy Staff (Diag Tech)', type: 'STAFF', userId: String(STAFF_USER.id), maxConcurrentEvents: 1 },
{ id: 'r5', name: 'Alignment Machine', type: 'EQUIPMENT', maxConcurrentEvents: 1 },
{ id: 'r6', name: 'Service Bay 3', type: 'ROOM', userId: String(RESOURCE_USER.id), maxConcurrentEvents: 1 },
];
export const SERVICES: Service[] = [
{ id: 's1', name: 'Full Synthetic Oil Change', durationMinutes: 60, price: 89.99, description: 'Premium oil and filter change.' },
{ id: 's2', name: 'Brake Pad Replacement', durationMinutes: 120, price: 245.00, description: 'Front and rear brake pad replacement.' },
{ id: 's3', name: 'Engine Diagnostics', durationMinutes: 90, price: 120.00, description: 'Full computer diagnostics of engine.' },
{ id: 's4', name: 'Tire Rotation', durationMinutes: 45, price: 40.00, description: 'Rotate and balance all four tires.' },
{ id: 's5', name: '4-Wheel Alignment', durationMinutes: 60, price: 95.50, description: 'Precision laser alignment.' },
{ id: 's6', name: 'Tire Patch', durationMinutes: 30, price: 25.00, description: 'Repair minor tire punctures.' },
{ id: 's7', name: 'Vehicle Inspection', durationMinutes: 60, price: 75.00, description: 'Comprehensive multi-point vehicle inspection.' },
{ id: 's1', name: 'Full Synthetic Oil Change', durationMinutes: 60, price: 89.99, description: 'Premium oil and filter change.', displayOrder: 1 },
{ id: 's2', name: 'Brake Pad Replacement', durationMinutes: 120, price: 245.00, description: 'Front and rear brake pad replacement.', displayOrder: 2 },
{ id: 's3', name: 'Engine Diagnostics', durationMinutes: 90, price: 120.00, description: 'Full computer diagnostics of engine.', displayOrder: 3 },
{ id: 's4', name: 'Tire Rotation', durationMinutes: 45, price: 40.00, description: 'Rotate and balance all four tires.', displayOrder: 4 },
{ id: 's5', name: '4-Wheel Alignment', durationMinutes: 60, price: 95.50, description: 'Precision laser alignment.', displayOrder: 5 },
{ id: 's6', name: 'Tire Patch', durationMinutes: 30, price: 25.00, description: 'Repair minor tire punctures.', displayOrder: 6 },
{ id: 's7', name: 'Vehicle Inspection', durationMinutes: 60, price: 75.00, description: 'Comprehensive multi-point vehicle inspection.', displayOrder: 7 },
];
const dayOffset = (days: number) => {
@@ -169,7 +170,7 @@ const customerUserCharlie: User = { id: 'u_cust_charlie', name: 'Charlie Day', e
export const CUSTOMERS: Customer[] = [
{
id: 'c1',
userId: CUSTOMER_USER.id,
userId: String(CUSTOMER_USER.id),
name: 'Alice Smith',
email: 'alice@example.com',
phone: '(555) 123-4567',
@@ -188,7 +189,7 @@ export const CUSTOMERS: Customer[] = [
},
{
id: 'c2',
userId: customerUserBob.id,
userId: String(customerUserBob.id),
name: 'Bob Jones',
email: 'bob.j@example.com',
phone: '(555) 987-6543',
@@ -203,7 +204,7 @@ export const CUSTOMERS: Customer[] = [
},
{
id: 'c3',
userId: customerUserCharlie.id,
userId: String(customerUserCharlie.id),
name: 'Charlie Day',
email: 'charlie@paddys.com',
phone: '(555) 444-3333',
@@ -296,10 +297,10 @@ export const ALL_BUSINESSES: Business[] = [
];
export const SUPPORT_TICKETS: Ticket[] = [
{ id: 't101', subject: 'Cannot connect custom domain', businessName: 'Prestige Worldwide', priority: 'High', status: 'Open', createdAt: new Date('2023-10-26T09:00:00') },
{ id: 't102', subject: 'Question about invoice #4022', businessName: 'Acme Auto Repair', priority: 'Low', status: 'In Progress', createdAt: new Date('2023-10-25T14:30:00') },
{ id: 't103', subject: 'Feature request: Group bookings', businessName: 'Tech Solutions', priority: 'Medium', status: 'Open', createdAt: new Date('2023-10-26T11:15:00') },
{ id: 't104', subject: 'Login issues for staff member', businessName: 'Mom & Pop Shop', priority: 'High', status: 'Resolved', createdAt: new Date('2023-10-24T16:45:00') },
{ id: 't101', subject: 'Cannot connect custom domain', description: 'Having issues connecting my custom domain.', ticketType: 'PLATFORM', priority: 'HIGH', status: 'OPEN', category: 'TECHNICAL', creator: 'u_owner_prestige', creatorEmail: 'brennan@prestige.com', creatorFullName: 'Brennan Huff', createdAt: '2023-10-26T09:00:00Z', updatedAt: '2023-10-26T09:00:00Z' },
{ id: 't102', subject: 'Question about invoice #4022', description: 'Need clarification on invoice charges.', ticketType: 'PLATFORM', priority: 'LOW', status: 'IN_PROGRESS', category: 'BILLING', creator: 'u1', creatorEmail: 'john@acme-auto.com', creatorFullName: 'John Owner', createdAt: '2023-10-25T14:30:00Z', updatedAt: '2023-10-25T14:30:00Z' },
{ id: 't103', subject: 'Feature request: Group bookings', description: 'Would like to request group booking feature.', ticketType: 'PLATFORM', priority: 'MEDIUM', status: 'OPEN', category: 'FEATURE_REQUEST', creator: 'u_owner_techsol', creatorEmail: 'owner@techsol.com', creatorFullName: 'Tech Solutions Owner', createdAt: '2023-10-26T11:15:00Z', updatedAt: '2023-10-26T11:15:00Z' },
{ id: 't104', subject: 'Login issues for staff member', description: 'Staff member cannot login.', ticketType: 'PLATFORM', priority: 'HIGH', status: 'RESOLVED', category: 'ACCOUNT', creator: 'u_owner_mompop', creatorEmail: 'owner@mompop.com', creatorFullName: 'Mom and Pop Owner', createdAt: '2023-10-24T16:45:00Z', updatedAt: '2023-10-24T16:45:00Z', resolvedAt: '2023-10-25T10:00:00Z' },
];
export const ALL_USERS: User[] = [

View File

@@ -0,0 +1,535 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Mail,
Plus,
Search,
Filter,
Edit2,
Trash2,
Copy,
Eye,
X,
Calendar,
Bell,
CheckCircle,
Megaphone,
FileText,
BarChart3,
Package,
AlertTriangle
} from 'lucide-react';
import api from '../api/client';
import { EmailTemplate, EmailTemplateCategory } from '../types';
import EmailTemplateForm from '../components/EmailTemplateForm';
// Category icon mapping
const categoryIcons: Record<EmailTemplateCategory, React.ReactNode> = {
APPOINTMENT: <Calendar className="h-4 w-4" />,
REMINDER: <Bell className="h-4 w-4" />,
CONFIRMATION: <CheckCircle className="h-4 w-4" />,
MARKETING: <Megaphone className="h-4 w-4" />,
NOTIFICATION: <FileText className="h-4 w-4" />,
REPORT: <BarChart3 className="h-4 w-4" />,
OTHER: <Package className="h-4 w-4" />,
};
// Category colors
const categoryColors: Record<EmailTemplateCategory, string> = {
APPOINTMENT: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
REMINDER: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
CONFIRMATION: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
MARKETING: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
NOTIFICATION: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300',
REPORT: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
OTHER: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
};
const EmailTemplates: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<EmailTemplateCategory | 'ALL'>('ALL');
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [templateToDelete, setTemplateToDelete] = useState<EmailTemplate | null>(null);
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [previewTemplate, setPreviewTemplate] = useState<EmailTemplate | null>(null);
// Fetch email templates
const { data: templates = [], isLoading, error } = useQuery<EmailTemplate[]>({
queryKey: ['email-templates'],
queryFn: async () => {
const { data } = await api.get('/api/email-templates/');
return data.map((t: any) => ({
id: String(t.id),
name: t.name,
description: t.description,
subject: t.subject,
htmlContent: t.html_content,
textContent: t.text_content,
scope: t.scope,
isDefault: t.is_default,
category: t.category,
previewContext: t.preview_context,
createdBy: t.created_by,
createdByName: t.created_by_name,
createdAt: t.created_at,
updatedAt: t.updated_at,
}));
},
});
// Delete template mutation
const deleteMutation = useMutation({
mutationFn: async (templateId: string) => {
await api.delete(`/api/email-templates/${templateId}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
setShowDeleteModal(false);
setTemplateToDelete(null);
},
});
// Duplicate template mutation
const duplicateMutation = useMutation({
mutationFn: async (templateId: string) => {
const { data } = await api.post(`/api/email-templates/${templateId}/duplicate/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
},
});
// Filter templates
const filteredTemplates = useMemo(() => {
let result = templates;
// Filter by category
if (selectedCategory !== 'ALL') {
result = result.filter(t => t.category === selectedCategory);
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(t =>
t.name.toLowerCase().includes(query) ||
t.description.toLowerCase().includes(query) ||
t.subject.toLowerCase().includes(query)
);
}
return result;
}, [templates, selectedCategory, searchQuery]);
const handleEdit = (template: EmailTemplate) => {
setEditingTemplate(template);
setShowCreateModal(true);
};
const handleDelete = (template: EmailTemplate) => {
setTemplateToDelete(template);
setShowDeleteModal(true);
};
const handleDuplicate = (template: EmailTemplate) => {
duplicateMutation.mutate(template.id);
};
const handlePreview = (template: EmailTemplate) => {
setPreviewTemplate(template);
setShowPreviewModal(true);
};
const handleFormClose = () => {
setShowCreateModal(false);
setEditingTemplate(null);
};
const handleFormSuccess = () => {
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
handleFormClose();
};
if (isLoading) {
return (
<div className="p-8">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-8">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-300">
{t('common.error')}: {error instanceof Error ? error.message : 'Unknown error'}
</p>
</div>
</div>
);
}
return (
<div className="p-8 space-y-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Mail className="h-7 w-7 text-brand-600" />
{t('emailTemplates.title', 'Email Templates')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{t('emailTemplates.description', 'Create and manage reusable email templates for your plugins')}
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
>
<Plus className="h-5 w-5" />
{t('emailTemplates.create', 'Create Template')}
</button>
</div>
{/* Search and Filters */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
<div className="flex flex-col lg:flex-row gap-4">
{/* Search Bar */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('emailTemplates.search', 'Search templates...')}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
{/* Category Filter */}
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-gray-400" />
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value as EmailTemplateCategory | 'ALL')}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="ALL">{t('emailTemplates.allCategories', 'All Categories')}</option>
<option value="APPOINTMENT">{t('emailTemplates.categoryAppointment', 'Appointment')}</option>
<option value="REMINDER">{t('emailTemplates.categoryReminder', 'Reminder')}</option>
<option value="CONFIRMATION">{t('emailTemplates.categoryConfirmation', 'Confirmation')}</option>
<option value="MARKETING">{t('emailTemplates.categoryMarketing', 'Marketing')}</option>
<option value="NOTIFICATION">{t('emailTemplates.categoryNotification', 'Notification')}</option>
<option value="REPORT">{t('emailTemplates.categoryReport', 'Report')}</option>
<option value="OTHER">{t('emailTemplates.categoryOther', 'Other')}</option>
</select>
</div>
</div>
{/* Active Filters Summary */}
{(searchQuery || selectedCategory !== 'ALL') && (
<div className="flex items-center gap-2 mt-4">
<span className="text-sm text-gray-500 dark:text-gray-400">
{t('emailTemplates.showing', 'Showing')} {filteredTemplates.length} {t('emailTemplates.results', 'results')}
</span>
{(searchQuery || selectedCategory !== 'ALL') && (
<button
onClick={() => {
setSearchQuery('');
setSelectedCategory('ALL');
}}
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
{t('common.clearAll', 'Clear all')}
</button>
)}
</div>
)}
</div>
{/* Templates List */}
{filteredTemplates.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<Mail className="h-12 w-12 mx-auto text-gray-400 mb-4" />
<p className="text-gray-500 dark:text-gray-400 mb-4">
{searchQuery || selectedCategory !== 'ALL'
? t('emailTemplates.noResults', 'No templates found matching your criteria')
: t('emailTemplates.empty', 'No email templates yet')}
</p>
{!searchQuery && selectedCategory === 'ALL' && (
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
>
<Plus className="h-4 w-4" />
{t('emailTemplates.createFirst', 'Create your first template')}
</button>
)}
</div>
) : (
<div className="space-y-4">
{filteredTemplates.map((template) => (
<div
key={template.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
>
<div className="p-6">
<div className="flex items-start justify-between">
{/* Template Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{template.name}
</h3>
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${categoryColors[template.category]}`}>
{categoryIcons[template.category]}
{template.category}
</span>
</div>
{template.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{template.description}
</p>
)}
<p className="text-sm text-gray-500 dark:text-gray-500 mb-3">
<span className="font-medium">{t('emailTemplates.subject', 'Subject')}:</span> {template.subject}
</p>
<div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1">
<span className="font-medium">
{t('emailTemplates.updatedAt', 'Updated')}:
</span>
<span>
{new Date(template.updatedAt).toLocaleDateString()}
</span>
</div>
{template.htmlContent && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300">
HTML
</span>
)}
{template.textContent && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
Text
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => handlePreview(template)}
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title={t('emailTemplates.preview', 'Preview')}
>
<Eye className="h-5 w-5" />
</button>
<button
onClick={() => handleDuplicate(template)}
disabled={duplicateMutation.isPending}
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
title={t('emailTemplates.duplicate', 'Duplicate')}
>
<Copy className="h-5 w-5" />
</button>
<button
onClick={() => handleEdit(template)}
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title={t('common.edit', 'Edit')}
>
<Edit2 className="h-5 w-5" />
</button>
<button
onClick={() => handleDelete(template)}
className="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
title={t('common.delete', 'Delete')}
>
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Create/Edit Modal */}
{showCreateModal && (
<EmailTemplateForm
template={editingTemplate}
onClose={handleFormClose}
onSuccess={handleFormSuccess}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && templateToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
{/* Modal Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('emailTemplates.confirmDelete', 'Delete Template')}
</h3>
</div>
<button
onClick={() => {
setShowDeleteModal(false);
setTemplateToDelete(null);
}}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Modal Body */}
<div className="p-6">
<p className="text-gray-600 dark:text-gray-400 mb-4">
{t('emailTemplates.deleteWarning', 'Are you sure you want to delete')} <span className="font-semibold text-gray-900 dark:text-white">{templateToDelete.name}</span>?
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('emailTemplates.deleteNote', 'This action cannot be undone. Plugins using this template may no longer work correctly.')}
</p>
</div>
{/* Modal Footer */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
onClick={() => {
setShowDeleteModal(false);
setTemplateToDelete(null);
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={() => deleteMutation.mutate(templateToDelete.id)}
disabled={deleteMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{deleteMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('common.deleting', 'Deleting...')}
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{t('common.delete', 'Delete')}
</>
)}
</button>
</div>
</div>
</div>
)}
{/* Preview Modal */}
{showPreviewModal && previewTemplate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Modal Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('emailTemplates.preview', 'Preview')}: {previewTemplate.name}
</h3>
<button
onClick={() => {
setShowPreviewModal(false);
setPreviewTemplate(null);
}}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Modal Body */}
<div className="p-6 overflow-y-auto flex-1">
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('emailTemplates.subject', 'Subject')}
</label>
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{previewTemplate.subject}
</div>
</div>
{previewTemplate.htmlContent && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('emailTemplates.htmlPreview', 'HTML Preview')}
</label>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<iframe
srcDoc={previewTemplate.htmlContent}
className="w-full h-96 bg-white"
title="Email Preview"
sandbox="allow-same-origin"
/>
</div>
</div>
)}
{previewTemplate.textContent && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('emailTemplates.textPreview', 'Plain Text Preview')}
</label>
<pre className="p-4 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white text-sm whitespace-pre-wrap font-mono overflow-auto max-h-48">
{previewTemplate.textContent}
</pre>
</div>
)}
</div>
{/* Modal Footer */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
onClick={() => {
setShowPreviewModal(false);
setPreviewTemplate(null);
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
>
{t('common.close', 'Close')}
</button>
<button
onClick={() => {
setShowPreviewModal(false);
handleEdit(previewTemplate);
}}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
>
<Edit2 className="h-4 w-4" />
{t('common.edit', 'Edit')}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default EmailTemplates;

View File

@@ -30,7 +30,19 @@ const LoginPage: React.FC = () => {
{ username, password },
{
onSuccess: (data) => {
const user = data.user;
// Check if MFA is required
if (data.mfa_required) {
// Store MFA challenge info in sessionStorage and redirect to MFA page
sessionStorage.setItem('mfa_challenge', JSON.stringify({
user_id: data.user_id,
mfa_methods: data.mfa_methods,
phone_last_4: data.phone_last_4,
}));
navigate('/mfa-verify');
return;
}
const user = data.user!;
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
const portStr = currentPort ? `:${currentPort}` : '';

View File

@@ -0,0 +1,620 @@
/**
* MFA Setup Page
* Allows users to enable/disable 2FA, manage phone verification, authenticator app, and backup codes
*/
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getMFAStatus,
sendPhoneVerification,
verifyPhone,
enableSMSMFA,
setupTOTP,
verifyTOTPSetup,
generateBackupCodes,
disableMFA,
listTrustedDevices,
revokeTrustedDevice,
revokeAllTrustedDevices,
MFAStatus,
TrustedDevice,
} from '../api/mfa';
import {
Shield,
Smartphone,
Key,
AlertCircle,
CheckCircle,
Loader2,
Copy,
Eye,
EyeOff,
Trash2,
Monitor,
RefreshCw,
} from 'lucide-react';
import toast from 'react-hot-toast';
const MFASetupPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// State
const [phoneNumber, setPhoneNumber] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [totpCode, setTotpCode] = useState('');
const [showBackupCodes, setShowBackupCodes] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [showDisableModal, setShowDisableModal] = useState(false);
const [disablePassword, setDisablePassword] = useState('');
const [totpSetupData, setTotpSetupData] = useState<{
secret: string;
qr_code: string;
provisioning_uri: string;
} | null>(null);
const [phoneSent, setPhoneSent] = useState(false);
// Queries
const { data: mfaStatus, isLoading: statusLoading } = useQuery({
queryKey: ['mfa-status'],
queryFn: getMFAStatus,
});
const { data: trustedDevicesData, isLoading: devicesLoading } = useQuery({
queryKey: ['trusted-devices'],
queryFn: listTrustedDevices,
enabled: !!mfaStatus?.mfa_enabled,
});
// Mutations
const sendPhoneCodeMutation = useMutation({
mutationFn: sendPhoneVerification,
onSuccess: () => {
setPhoneSent(true);
toast.success('Verification code sent!');
},
onError: (err: any) => {
toast.error(err.response?.data?.error || 'Failed to send code');
},
});
const verifyPhoneMutation = useMutation({
mutationFn: verifyPhone,
onSuccess: () => {
toast.success('Phone verified!');
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
setVerificationCode('');
},
onError: (err: any) => {
toast.error(err.response?.data?.error || 'Invalid code');
},
});
const enableSMSMutation = useMutation({
mutationFn: enableSMSMFA,
onSuccess: (data) => {
toast.success('SMS MFA enabled!');
if (data.backup_codes) {
setBackupCodes(data.backup_codes);
setShowBackupCodes(true);
}
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
},
onError: (err: any) => {
toast.error(err.response?.data?.error || 'Failed to enable SMS MFA');
},
});
const setupTOTPMutation = useMutation({
mutationFn: setupTOTP,
onSuccess: (data) => {
setTotpSetupData({
secret: data.secret,
qr_code: data.qr_code,
provisioning_uri: data.provisioning_uri,
});
},
onError: (err: any) => {
toast.error(err.response?.data?.error || 'Failed to setup authenticator');
},
});
const verifyTOTPMutation = useMutation({
mutationFn: verifyTOTPSetup,
onSuccess: (data) => {
toast.success('Authenticator app configured!');
if (data.backup_codes) {
setBackupCodes(data.backup_codes);
setShowBackupCodes(true);
}
setTotpSetupData(null);
setTotpCode('');
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
},
onError: (err: any) => {
toast.error(err.response?.data?.error || 'Invalid code');
},
});
const generateBackupCodesMutation = useMutation({
mutationFn: generateBackupCodes,
onSuccess: (data) => {
setBackupCodes(data.backup_codes);
setShowBackupCodes(true);
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
toast.success('New backup codes generated!');
},
onError: (err: any) => {
toast.error(err.response?.data?.error || 'Failed to generate codes');
},
});
const disableMFAMutation = useMutation({
mutationFn: disableMFA,
onSuccess: () => {
toast.success('Two-factor authentication disabled');
setShowDisableModal(false);
setDisablePassword('');
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
},
onError: (err: any) => {
toast.error(err.response?.data?.error || 'Invalid password');
},
});
const revokeDeviceMutation = useMutation({
mutationFn: revokeTrustedDevice,
onSuccess: () => {
toast.success('Device trust revoked');
queryClient.invalidateQueries({ queryKey: ['trusted-devices'] });
},
onError: (err: any) => {
toast.error(err.response?.data?.error || 'Failed to revoke device');
},
});
const revokeAllDevicesMutation = useMutation({
mutationFn: revokeAllTrustedDevices,
onSuccess: () => {
toast.success('All devices revoked');
queryClient.invalidateQueries({ queryKey: ['trusted-devices'] });
},
onError: (err: any) => {
toast.error(err.response?.data?.error || 'Failed to revoke devices');
},
});
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard!');
};
if (statusLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
</div>
);
}
const trustedDevices = trustedDevicesData?.devices || [];
return (
<div className="max-w-4xl mx-auto p-6 space-y-8">
{/* Header */}
<div className="flex items-center gap-4">
<div className="p-3 bg-brand-100 dark:bg-brand-900/30 rounded-full">
<Shield className="h-8 w-8 text-brand-600 dark:text-brand-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Two-Factor Authentication
</h1>
<p className="text-gray-600 dark:text-gray-400">
Add an extra layer of security to your account
</p>
</div>
{mfaStatus?.mfa_enabled && (
<div className="ml-auto flex items-center gap-2 text-green-600 dark:text-green-400">
<CheckCircle className="h-5 w-5" />
<span className="font-medium">Enabled</span>
</div>
)}
</div>
{/* Backup Codes Modal */}
{showBackupCodes && backupCodes.length > 0 && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-md w-full p-6 space-y-4">
<div className="flex items-center gap-3">
<Key className="h-6 w-6 text-amber-500" />
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Save Your Backup Codes
</h2>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Save these codes in a safe place. Each code can only be used once to access your account if you lose your phone.
</p>
<div className="bg-gray-100 dark:bg-gray-700 rounded-lg p-4 font-mono text-sm">
<div className="grid grid-cols-2 gap-2">
{backupCodes.map((code, index) => (
<div key={index} className="text-gray-800 dark:text-gray-200">
{code}
</div>
))}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => copyToClipboard(backupCodes.join('\n'))}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Copy className="h-4 w-4" />
Copy All
</button>
<button
onClick={() => setShowBackupCodes(false)}
className="flex-1 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
>
I've Saved These
</button>
</div>
</div>
</div>
)}
{/* Disable MFA Modal */}
{showDisableModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-md w-full p-6 space-y-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Disable Two-Factor Authentication
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Enter your password to disable 2FA. This will make your account less secure.
</p>
<input
type="password"
value={disablePassword}
onChange={(e) => setDisablePassword(e.target.value)}
placeholder="Enter your password"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<div className="flex gap-2">
<button
onClick={() => {
setShowDisableModal(false);
setDisablePassword('');
}}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
onClick={() => disableMFAMutation.mutate({ password: disablePassword })}
disabled={!disablePassword || disableMFAMutation.isPending}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>
{disableMFAMutation.isPending ? (
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
) : (
'Disable 2FA'
)}
</button>
</div>
</div>
</div>
)}
{/* SMS Setup */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-3 mb-4">
<Smartphone className="h-6 w-6 text-blue-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
SMS Authentication
</h2>
{mfaStatus?.phone_verified && (
<span className="ml-auto text-sm text-green-600 dark:text-green-400 flex items-center gap-1">
<CheckCircle className="h-4 w-4" />
Phone verified
</span>
)}
</div>
{!mfaStatus?.phone_verified ? (
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Verify your phone number to receive verification codes via SMS.
</p>
<div className="flex gap-2">
<input
type="tel"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+1 (555) 000-0000"
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<button
onClick={() => sendPhoneCodeMutation.mutate(phoneNumber)}
disabled={!phoneNumber || sendPhoneCodeMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
>
{sendPhoneCodeMutation.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
'Send Code'
)}
</button>
</div>
{phoneSent && (
<div className="flex gap-2">
<input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="6-digit code"
maxLength={6}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<button
onClick={() => verifyPhoneMutation.mutate(verificationCode)}
disabled={verificationCode.length !== 6 || verifyPhoneMutation.isPending}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
>
{verifyPhoneMutation.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
'Verify'
)}
</button>
</div>
)}
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Your phone ending in {mfaStatus.phone_last_4} is verified.
{mfaStatus.mfa_method?.includes('SMS') || mfaStatus.mfa_method === 'BOTH'
? ' SMS authentication is enabled.'
: ' Enable SMS to use it for verification.'}
</p>
{!(mfaStatus.mfa_method?.includes('SMS') || mfaStatus.mfa_method === 'BOTH') && (
<button
onClick={() => enableSMSMutation.mutate()}
disabled={enableSMSMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
>
{enableSMSMutation.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
'Enable SMS Authentication'
)}
</button>
)}
</div>
)}
</div>
{/* TOTP Setup */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-3 mb-4">
<Shield className="h-6 w-6 text-purple-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Authenticator App
</h2>
{mfaStatus?.totp_verified && (
<span className="ml-auto text-sm text-green-600 dark:text-green-400 flex items-center gap-1">
<CheckCircle className="h-4 w-4" />
Configured
</span>
)}
</div>
{!mfaStatus?.totp_verified ? (
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Use an authenticator app like Google Authenticator, Authy, or 1Password to generate verification codes.
</p>
{!totpSetupData ? (
<button
onClick={() => setupTOTPMutation.mutate()}
disabled={setupTOTPMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
>
{setupTOTPMutation.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
'Set Up Authenticator App'
)}
</button>
) : (
<div className="space-y-4">
<div className="flex flex-col md:flex-row gap-6 items-center">
<div className="bg-white p-4 rounded-lg">
<img
src={totpSetupData.qr_code}
alt="QR Code"
className="w-48 h-48"
/>
</div>
<div className="flex-1 space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-400">
1. Scan this QR code with your authenticator app
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
2. Or manually enter this key:
</p>
<div className="flex items-center gap-2">
<code className="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded font-mono text-sm break-all">
{totpSetupData.secret}
</code>
<button
onClick={() => copyToClipboard(totpSetupData.secret)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
</div>
<div className="flex gap-2">
<input
type="text"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
placeholder="Enter 6-digit code"
maxLength={6}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<button
onClick={() => verifyTOTPMutation.mutate(totpCode)}
disabled={totpCode.length !== 6 || verifyTOTPMutation.isPending}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
>
{verifyTOTPMutation.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
'Verify'
)}
</button>
</div>
<button
onClick={() => setTotpSetupData(null)}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Cancel
</button>
</div>
)}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">
Your authenticator app is configured and ready to use.
</p>
)}
</div>
{/* Backup Codes */}
{mfaStatus?.mfa_enabled && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-3 mb-4">
<Key className="h-6 w-6 text-amber-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Backup Codes
</h2>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Backup codes can be used to access your account if you lose your phone.
You have <strong>{mfaStatus.backup_codes_count}</strong> codes remaining.
{mfaStatus.backup_codes_generated_at && (
<span className="block mt-1 text-xs">
Generated on {new Date(mfaStatus.backup_codes_generated_at).toLocaleDateString()}
</span>
)}
</p>
<button
onClick={() => generateBackupCodesMutation.mutate()}
disabled={generateBackupCodesMutation.isPending}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{generateBackupCodesMutation.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<>
<RefreshCw className="h-4 w-4" />
Generate New Codes
</>
)}
</button>
</div>
)}
{/* Trusted Devices */}
{mfaStatus?.mfa_enabled && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Monitor className="h-6 w-6 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Trusted Devices
</h2>
</div>
{trustedDevices.length > 0 && (
<button
onClick={() => revokeAllDevicesMutation.mutate()}
disabled={revokeAllDevicesMutation.isPending}
className="text-sm text-red-600 hover:text-red-700 dark:text-red-400"
>
Revoke All
</button>
)}
</div>
{devicesLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
) : trustedDevices.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
No trusted devices. When you log in and check "Trust this device", it will appear here.
</p>
) : (
<div className="space-y-3">
{trustedDevices.map((device) => (
<div
key={device.id}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
>
<div className="flex items-center gap-3">
<Monitor className="h-5 w-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{device.name || 'Unknown Device'}
{device.is_current && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400">
(Current)
</span>
)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{device.ip_address} Last used {new Date(device.last_used_at).toLocaleDateString()}
</p>
</div>
</div>
<button
onClick={() => revokeDeviceMutation.mutate(device.id)}
disabled={revokeDeviceMutation.isPending}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Disable 2FA */}
{mfaStatus?.mfa_enabled && (
<div className="bg-red-50 dark:bg-red-900/20 rounded-xl border border-red-200 dark:border-red-800 p-6">
<h2 className="text-lg font-semibold text-red-800 dark:text-red-200 mb-2">
Disable Two-Factor Authentication
</h2>
<p className="text-sm text-red-700 dark:text-red-300 mb-4">
Disabling 2FA will make your account less secure. You will no longer need a verification code to log in.
</p>
<button
onClick={() => setShowDisableModal(true)}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Disable 2FA
</button>
</div>
)}
</div>
);
};
export default MFASetupPage;

View File

@@ -0,0 +1,431 @@
/**
* MFA Verification Page
* Shown when user has MFA enabled and needs to complete verification during login
*/
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { sendMFALoginCode, verifyMFALogin } from '../api/mfa';
import { setCookie } from '../utils/cookies';
import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
import {
AlertCircle,
Loader2,
Shield,
Smartphone,
Key,
ArrowLeft,
CheckCircle
} from 'lucide-react';
interface MFAChallenge {
user_id: number;
mfa_methods: ('SMS' | 'TOTP' | 'BACKUP')[];
phone_last_4: string | null;
}
const MFAVerifyPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [challenge, setChallenge] = useState<MFAChallenge | null>(null);
const [selectedMethod, setSelectedMethod] = useState<'SMS' | 'TOTP' | 'BACKUP' | null>(null);
const [code, setCode] = useState(['', '', '', '', '', '']);
const [backupCode, setBackupCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [smsSent, setSmsSent] = useState(false);
const [trustDevice, setTrustDevice] = useState(false);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => {
// Get MFA challenge from sessionStorage
const storedChallenge = sessionStorage.getItem('mfa_challenge');
if (!storedChallenge) {
navigate('/login');
return;
}
const parsed = JSON.parse(storedChallenge) as MFAChallenge;
setChallenge(parsed);
// Default to TOTP if available, otherwise SMS
if (parsed.mfa_methods.includes('TOTP')) {
setSelectedMethod('TOTP');
} else if (parsed.mfa_methods.includes('SMS')) {
setSelectedMethod('SMS');
}
}, [navigate]);
const handleSendSMS = async () => {
if (!challenge) return;
setLoading(true);
setError('');
try {
await sendMFALoginCode(challenge.user_id, 'SMS');
setSmsSent(true);
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to send SMS code');
} finally {
setLoading(false);
}
};
const handleCodeChange = (index: number, value: string) => {
// Only allow digits
if (value && !/^\d$/.test(value)) return;
const newCode = [...code];
newCode[index] = value;
setCode(newCode);
// Auto-focus next input
if (value && index < 5) {
inputRefs.current[index + 1]?.focus();
}
};
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
if (e.key === 'Backspace' && !code[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6);
const newCode = [...code];
for (let i = 0; i < pastedData.length; i++) {
newCode[i] = pastedData[i];
}
setCode(newCode);
// Focus the next empty input or the last one
const nextEmptyIndex = newCode.findIndex(c => !c);
inputRefs.current[nextEmptyIndex === -1 ? 5 : nextEmptyIndex]?.focus();
};
const handleVerify = async () => {
if (!challenge || !selectedMethod) return;
const verificationCode = selectedMethod === 'BACKUP'
? backupCode.trim()
: code.join('');
if (selectedMethod !== 'BACKUP' && verificationCode.length !== 6) {
setError('Please enter a 6-digit code');
return;
}
if (selectedMethod === 'BACKUP' && !verificationCode) {
setError('Please enter a backup code');
return;
}
setLoading(true);
setError('');
try {
const response = await verifyMFALogin(
challenge.user_id,
verificationCode,
selectedMethod,
trustDevice
);
// Clear MFA challenge from storage
sessionStorage.removeItem('mfa_challenge');
// Store tokens
setCookie('access_token', response.access, 7);
setCookie('refresh_token', response.refresh, 30);
// Get redirect info from user
const user = response.user;
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
const portStr = currentPort ? `:${currentPort}` : '';
// Determine target subdomain
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
let targetSubdomain: string | null = null;
if (isPlatformUser) {
targetSubdomain = 'platform';
} else if (user.business_subdomain) {
targetSubdomain = user.business_subdomain;
}
// Check if we need to redirect
const isOnTargetSubdomain = currentHostname === `${targetSubdomain}.lvh.me`;
const needsRedirect = targetSubdomain && !isOnTargetSubdomain;
if (needsRedirect) {
window.location.href = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${response.access}&refresh_token=${response.refresh}`;
return;
}
// Navigate to dashboard
navigate('/');
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid verification code');
} finally {
setLoading(false);
}
};
const getMethodIcon = (method: string) => {
switch (method) {
case 'SMS':
return <Smartphone className="h-5 w-5" />;
case 'TOTP':
return <Shield className="h-5 w-5" />;
case 'BACKUP':
return <Key className="h-5 w-5" />;
default:
return null;
}
};
const getMethodLabel = (method: string) => {
switch (method) {
case 'SMS':
return challenge?.phone_last_4
? `SMS to ***-***-${challenge.phone_last_4}`
: 'SMS Code';
case 'TOTP':
return 'Authenticator App';
case 'BACKUP':
return 'Backup Code';
default:
return method;
}
};
if (!challenge) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
</div>
);
}
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="p-3 bg-brand-100 dark:bg-brand-900/30 rounded-full">
<Shield className="h-8 w-8 text-brand-600 dark:text-brand-400" />
</div>
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Two-Factor Authentication
</h1>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Enter a verification code to complete login
</p>
</div>
{/* Error Message */}
{error && (
<div className="mb-6 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-100 dark:border-red-800/50">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-red-500 dark:text-red-400 flex-shrink-0" />
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
</div>
)}
{/* Method Selection */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
{/* Method Tabs */}
{challenge.mfa_methods.length > 1 && (
<div className="flex gap-2 mb-6">
{challenge.mfa_methods.map((method) => (
<button
key={method}
onClick={() => {
setSelectedMethod(method);
setCode(['', '', '', '', '', '']);
setBackupCode('');
setError('');
}}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedMethod === method
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{getMethodIcon(method)}
<span className="hidden sm:inline">{method === 'TOTP' ? 'App' : method === 'BACKUP' ? 'Backup' : 'SMS'}</span>
</button>
))}
</div>
)}
{/* SMS Method */}
{selectedMethod === 'SMS' && (
<div className="space-y-4">
{!smsSent ? (
<>
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
We'll send a verification code to your phone ending in{' '}
<span className="font-medium">{challenge.phone_last_4}</span>
</p>
<button
onClick={handleSendSMS}
disabled={loading}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 hover:bg-brand-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Sending...
</>
) : (
<>
<Smartphone className="h-5 w-5" />
Send Code
</>
)}
</button>
</>
) : (
<>
<div className="flex items-center justify-center gap-2 text-green-600 dark:text-green-400 mb-4">
<CheckCircle className="h-5 w-5" />
<span className="text-sm">Code sent!</span>
</div>
<div className="flex justify-center gap-2" onPaste={handlePaste}>
{code.map((digit, index) => (
<input
key={index}
ref={(el) => { inputRefs.current[index] = el; }}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleCodeChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
className="w-12 h-14 text-center text-2xl font-semibold border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:border-brand-500 focus:ring-brand-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
autoFocus={index === 0}
/>
))}
</div>
<button
onClick={handleSendSMS}
disabled={loading}
className="text-sm text-brand-600 dark:text-brand-400 hover:underline block mx-auto mt-2"
>
Resend code
</button>
</>
)}
</div>
)}
{/* TOTP Method */}
{selectedMethod === 'TOTP' && (
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
Enter the 6-digit code from your authenticator app
</p>
<div className="flex justify-center gap-2" onPaste={handlePaste}>
{code.map((digit, index) => (
<input
key={index}
ref={(el) => { inputRefs.current[index] = el; }}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleCodeChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
className="w-12 h-14 text-center text-2xl font-semibold border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:border-brand-500 focus:ring-brand-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
autoFocus={index === 0}
/>
))}
</div>
</div>
)}
{/* Backup Code Method */}
{selectedMethod === 'BACKUP' && (
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
Enter one of your backup codes
</p>
<input
type="text"
value={backupCode}
onChange={(e) => setBackupCode(e.target.value.toUpperCase())}
placeholder="XXXX-XXXX"
className="w-full text-center text-lg font-mono tracking-wider border-2 border-gray-300 dark:border-gray-600 rounded-lg py-3 focus:border-brand-500 focus:ring-brand-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400"
autoFocus
/>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
Each backup code can only be used once
</p>
</div>
)}
{/* Trust Device Checkbox */}
<div className="mt-6 flex items-center gap-2">
<input
type="checkbox"
id="trust-device"
checked={trustDevice}
onChange={(e) => setTrustDevice(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<label htmlFor="trust-device" className="text-sm text-gray-600 dark:text-gray-400">
Trust this device for 30 days
</label>
</div>
{/* Verify Button */}
{((selectedMethod === 'SMS' && smsSent) || selectedMethod === 'TOTP' || selectedMethod === 'BACKUP') && (
<button
onClick={handleVerify}
disabled={loading}
className="w-full mt-6 flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 hover:bg-brand-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Verifying...
</>
) : (
'Verify'
)}
</button>
)}
</div>
{/* Back to Login */}
<button
onClick={() => {
sessionStorage.removeItem('mfa_challenge');
navigate('/login');
}}
className="mt-6 flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mx-auto"
>
<ArrowLeft className="h-4 w-4" />
Back to login
</button>
{/* Footer */}
<div className="mt-8 flex justify-center">
<SmoothScheduleLogo className="h-6 w-6 text-gray-400" />
</div>
</div>
</div>
);
};
export default MFAVerifyPage;

View File

@@ -24,6 +24,7 @@ import {
} from 'lucide-react';
import api from '../api/client';
import { PluginInstallation, PluginCategory } from '../types';
import EmailTemplateSelector from '../components/EmailTemplateSelector';
// Category icon mapping
const categoryIcons: Record<PluginCategory, React.ReactNode> = {
@@ -689,7 +690,13 @@ const MyPlugins: React.FC = () => {
{variable.required && <span className="text-red-500 ml-1">*</span>}
</label>
{variable.type === 'textarea' ? (
{variable.type === 'email_template' ? (
<EmailTemplateSelector
value={configValues[key] || variable.default}
onChange={(templateId) => setConfigValues({ ...configValues, [key]: templateId })}
required={variable.required}
/>
) : variable.type === 'textarea' ? (
<textarea
value={configValues[key] !== undefined ? configValues[key] : (variable.default ? unescapeString(variable.default) : '')}
onChange={(e) => setConfigValues({ ...configValues, [key]: e.target.value })}

View File

@@ -25,6 +25,12 @@ import {
Lock,
Users,
ExternalLink,
Mail,
Clock,
Server,
Play,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import {
usePlatformSettings,
@@ -42,14 +48,23 @@ import {
usePlatformOAuthSettings,
useUpdatePlatformOAuthSettings,
} from '../../hooks/usePlatformOAuth';
import {
useTicketEmailSettings,
useUpdateTicketEmailSettings,
useTestImapConnection,
useTestSmtpConnection,
useFetchEmailsNow,
} from '../../hooks/useTicketEmailSettings';
import { Send } from 'lucide-react';
type TabType = 'stripe' | 'tiers' | 'oauth';
type TabType = 'general' | 'stripe' | 'tiers' | 'oauth';
const PlatformSettings: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>('stripe');
const [activeTab, setActiveTab] = useState<TabType>('general');
const tabs: { id: TabType; label: string; icon: React.ElementType }[] = [
{ id: 'general', label: t('platform.settings.general', 'General'), icon: Settings },
{ id: 'stripe', label: 'Stripe', icon: CreditCard },
{ id: 'tiers', label: t('platform.settings.tiersPricing'), icon: Layers },
{ id: 'oauth', label: t('platform.settings.oauthProviders'), icon: Users },
@@ -94,6 +109,7 @@ const PlatformSettings: React.FC = () => {
</div>
{/* Tab Content */}
{activeTab === 'general' && <GeneralSettingsTab />}
{activeTab === 'stripe' && <StripeSettingsTab />}
{activeTab === 'tiers' && <TiersSettingsTab />}
{activeTab === 'oauth' && <OAuthSettingsTab />}
@@ -101,6 +117,692 @@ const PlatformSettings: React.FC = () => {
);
};
const GeneralSettingsTab: React.FC = () => {
const { t } = useTranslation();
const { data: emailSettings, isLoading, error } = useTicketEmailSettings();
const updateMutation = useUpdateTicketEmailSettings();
const testImapMutation = useTestImapConnection();
const testSmtpMutation = useTestSmtpConnection();
const fetchNowMutation = useFetchEmailsNow();
const [formData, setFormData] = useState({
// IMAP settings
imap_host: '',
imap_port: 993,
imap_use_ssl: true,
imap_username: '',
imap_password: '',
imap_folder: 'INBOX',
// SMTP settings
smtp_host: '',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: '',
smtp_password: '',
smtp_from_email: '',
smtp_from_name: '',
// General settings
support_email_address: '',
support_email_domain: '',
is_enabled: false,
delete_after_processing: true,
check_interval_seconds: 60,
});
const [showImapPassword, setShowImapPassword] = useState(false);
const [showSmtpPassword, setShowSmtpPassword] = useState(false);
const [isImapExpanded, setIsImapExpanded] = useState(false);
const [isSmtpExpanded, setIsSmtpExpanded] = useState(false);
// Update form when settings load
React.useEffect(() => {
if (emailSettings) {
setFormData({
// IMAP settings
imap_host: emailSettings.imap_host || '',
imap_port: emailSettings.imap_port || 993,
imap_use_ssl: emailSettings.imap_use_ssl ?? true,
imap_username: emailSettings.imap_username || '',
imap_password: '', // Don't prefill password
imap_folder: emailSettings.imap_folder || 'INBOX',
// SMTP settings
smtp_host: emailSettings.smtp_host || '',
smtp_port: emailSettings.smtp_port || 587,
smtp_use_tls: emailSettings.smtp_use_tls ?? true,
smtp_use_ssl: emailSettings.smtp_use_ssl ?? false,
smtp_username: emailSettings.smtp_username || '',
smtp_password: '', // Don't prefill password
smtp_from_email: emailSettings.smtp_from_email || '',
smtp_from_name: emailSettings.smtp_from_name || '',
// General settings
support_email_address: emailSettings.support_email_address || '',
support_email_domain: emailSettings.support_email_domain || '',
is_enabled: emailSettings.is_enabled ?? false,
delete_after_processing: emailSettings.delete_after_processing ?? true,
check_interval_seconds: emailSettings.check_interval_seconds || 60,
});
}
}, [emailSettings]);
const handleSave = async () => {
// Only send passwords if they were changed
const dataToSend = { ...formData };
if (!dataToSend.imap_password) {
delete (dataToSend as any).imap_password;
}
if (!dataToSend.smtp_password) {
delete (dataToSend as any).smtp_password;
}
await updateMutation.mutateAsync(dataToSend);
};
const handleTestImap = async () => {
await testImapMutation.mutateAsync();
};
const handleTestSmtp = async () => {
await testSmtpMutation.mutateAsync();
};
const handleFetchNow = async () => {
await fetchNowMutation.mutateAsync();
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
<AlertCircle className="w-5 h-5" />
<span>Failed to load email settings</span>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Email Processing Status */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Mail className="w-5 h-5" />
{t('platform.settings.emailProcessing', 'Support Email Processing')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
{emailSettings?.is_enabled ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<AlertCircle className="w-5 h-5 text-yellow-500" />
)}
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Status</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{emailSettings?.is_enabled ? 'Enabled' : 'Disabled'}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
{emailSettings?.is_imap_configured ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<AlertCircle className="w-5 h-5 text-yellow-500" />
)}
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">IMAP (Inbound)</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{emailSettings?.is_imap_configured ? 'Configured' : 'Not configured'}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
{emailSettings?.is_smtp_configured ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<AlertCircle className="w-5 h-5 text-yellow-500" />
)}
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">SMTP (Outbound)</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{emailSettings?.is_smtp_configured ? 'Configured' : 'Not configured'}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Clock className="w-5 h-5 text-blue-500" />
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Last Check</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{emailSettings?.last_check_at
? new Date(emailSettings.last_check_at).toLocaleString()
: 'Never'}
</p>
</div>
</div>
</div>
{emailSettings?.last_error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">
<span className="font-medium">Last Error:</span> {emailSettings.last_error}
</p>
</div>
)}
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Emails processed: <strong>{emailSettings?.emails_processed_count || 0}</strong></span>
<span>Check interval: <strong>{emailSettings?.check_interval_seconds || 60}s</strong></span>
</div>
</div>
{/* IMAP Configuration */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => setIsImapExpanded(!isImapExpanded)}
className="w-full p-6 flex items-center justify-between text-left"
>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Server className="w-5 h-5" />
{t('platform.settings.imapConfig', 'IMAP Server Configuration (Inbound)')}
{emailSettings?.is_imap_configured && (
<span className="ml-2 px-2 py-0.5 text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
Configured
</span>
)}
</h2>
{isImapExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-500" />
) : (
<ChevronDown className="w-5 h-5 text-gray-500" />
)}
</button>
{isImapExpanded && (
<div className="px-6 pb-6 space-y-4">
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div>
<p className="font-medium text-gray-900 dark:text-white">Enable Email Processing</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Automatically fetch and process incoming support emails
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.is_enabled}
onChange={(e) => setFormData((prev) => ({ ...prev, is_enabled: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-500 peer-checked:bg-blue-600"></div>
</label>
</div>
{/* Server Settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
IMAP Host
</label>
<input
type="text"
value={formData.imap_host}
onChange={(e) => setFormData((prev) => ({ ...prev, imap_host: e.target.value }))}
placeholder="mail.talova.net"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Port
</label>
<input
type="number"
value={formData.imap_port}
onChange={(e) => setFormData((prev) => ({ ...prev, imap_port: parseInt(e.target.value) || 993 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.imap_use_ssl}
onChange={(e) => setFormData((prev) => ({ ...prev, imap_use_ssl: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Use SSL</span>
</label>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username
</label>
<input
type="text"
value={formData.imap_username}
onChange={(e) => setFormData((prev) => ({ ...prev, imap_username: e.target.value }))}
placeholder="support@yourdomain.com"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password
</label>
<div className="relative">
<input
type={showImapPassword ? 'text' : 'password'}
value={formData.imap_password}
onChange={(e) => setFormData((prev) => ({ ...prev, imap_password: e.target.value }))}
placeholder={emailSettings?.imap_password_masked || 'Enter password'}
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<button
type="button"
onClick={() => setShowImapPassword(!showImapPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{showImapPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Folder
</label>
<input
type="text"
value={formData.imap_folder}
onChange={(e) => setFormData((prev) => ({ ...prev, imap_folder: e.target.value }))}
placeholder="INBOX"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Support Email Domain
</label>
<input
type="text"
value={formData.support_email_domain}
onChange={(e) => setFormData((prev) => ({ ...prev, support_email_domain: e.target.value }))}
placeholder="mail.talova.net"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Domain for reply-to addresses (e.g., support+ticket-123@domain)
</p>
</div>
</div>
{/* Test IMAP Button */}
<div className="flex gap-3">
<button
onClick={handleTestImap}
disabled={testImapMutation.isPending || !formData.imap_host}
className="inline-flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
{testImapMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Testing IMAP...
</>
) : (
<>
<RefreshCw className="w-4 h-4" />
Test IMAP Connection
</>
)}
</button>
</div>
{testImapMutation.isSuccess && (
<div className={`p-3 rounded-lg ${
testImapMutation.data?.success
? 'bg-green-50 dark:bg-green-900/20'
: 'bg-red-50 dark:bg-red-900/20'
}`}>
<p className={`text-sm flex items-center gap-2 ${
testImapMutation.data?.success
? 'text-green-700 dark:text-green-400'
: 'text-red-700 dark:text-red-400'
}`}>
{testImapMutation.data?.success ? (
<CheckCircle className="w-4 h-4" />
) : (
<AlertCircle className="w-4 h-4" />
)}
{testImapMutation.data?.message}
</p>
</div>
)}
</div>
)}
</div>
{/* SMTP Configuration */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => setIsSmtpExpanded(!isSmtpExpanded)}
className="w-full p-6 flex items-center justify-between text-left"
>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Send className="w-5 h-5" />
{t('platform.settings.smtpConfig', 'SMTP Server Configuration (Outbound)')}
{emailSettings?.is_smtp_configured && (
<span className="ml-2 px-2 py-0.5 text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
Configured
</span>
)}
</h2>
{isSmtpExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-500" />
) : (
<ChevronDown className="w-5 h-5 text-gray-500" />
)}
</button>
{isSmtpExpanded && (
<div className="px-6 pb-6 space-y-4">
{/* Server Settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
SMTP Host
</label>
<input
type="text"
value={formData.smtp_host}
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_host: e.target.value }))}
placeholder="smtp.example.com"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Port
</label>
<input
type="number"
value={formData.smtp_port}
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_port: parseInt(e.target.value) || 587 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.smtp_use_tls}
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_use_tls: e.target.checked, smtp_use_ssl: e.target.checked ? false : prev.smtp_use_ssl }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">TLS</span>
</label>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.smtp_use_ssl}
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_use_ssl: e.target.checked, smtp_use_tls: e.target.checked ? false : prev.smtp_use_tls }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">SSL</span>
</label>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username
</label>
<input
type="text"
value={formData.smtp_username}
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_username: e.target.value }))}
placeholder="user@example.com"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password
</label>
<div className="relative">
<input
type={showSmtpPassword ? 'text' : 'password'}
value={formData.smtp_password}
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_password: e.target.value }))}
placeholder={emailSettings?.smtp_password_masked || 'Enter password'}
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<button
type="button"
onClick={() => setShowSmtpPassword(!showSmtpPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{showSmtpPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
From Email
</label>
<input
type="email"
value={formData.smtp_from_email}
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_from_email: e.target.value }))}
placeholder="support@yourdomain.com"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
From Name
</label>
<input
type="text"
value={formData.smtp_from_name}
onChange={(e) => setFormData((prev) => ({ ...prev, smtp_from_name: e.target.value }))}
placeholder="SmoothSchedule Support"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
{/* Test SMTP Button */}
<div className="flex gap-3">
<button
onClick={handleTestSmtp}
disabled={testSmtpMutation.isPending || !formData.smtp_host}
className="inline-flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
{testSmtpMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Testing SMTP...
</>
) : (
<>
<RefreshCw className="w-4 h-4" />
Test SMTP Connection
</>
)}
</button>
</div>
{testSmtpMutation.isSuccess && (
<div className={`p-3 rounded-lg ${
testSmtpMutation.data?.success
? 'bg-green-50 dark:bg-green-900/20'
: 'bg-red-50 dark:bg-red-900/20'
}`}>
<p className={`text-sm flex items-center gap-2 ${
testSmtpMutation.data?.success
? 'text-green-700 dark:text-green-400'
: 'text-red-700 dark:text-red-400'
}`}>
{testSmtpMutation.data?.success ? (
<CheckCircle className="w-4 h-4" />
) : (
<AlertCircle className="w-4 h-4" />
)}
{testSmtpMutation.data?.message}
</p>
</div>
)}
</div>
)}
</div>
{/* Processing Settings */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Settings className="w-5 h-5" />
{t('platform.settings.processingSettings', 'Processing Settings')}
</h2>
<div className="space-y-4">
<h3 className="font-medium text-gray-900 dark:text-white mb-4">Email Fetching</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Check Interval (seconds)
</label>
<input
type="number"
min="10"
max="3600"
value={formData.check_interval_seconds}
onChange={(e) => setFormData((prev) => ({ ...prev, check_interval_seconds: parseInt(e.target.value) || 60 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
How often to check for new emails (10-3600 seconds)
</p>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.delete_after_processing}
onChange={(e) => setFormData((prev) => ({ ...prev, delete_after_processing: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Delete emails after processing
</span>
</label>
</div>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleSave}
disabled={updateMutation.isPending}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{updateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : (
'Save Settings'
)}
</button>
<button
onClick={handleFetchNow}
disabled={fetchNowMutation.isPending || !emailSettings?.is_imap_configured}
className="inline-flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
{fetchNowMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Fetching...
</>
) : (
<>
<Play className="w-4 h-4" />
Fetch Now
</>
)}
</button>
</div>
{/* Status Messages */}
{updateMutation.isSuccess && (
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<p className="text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Settings saved successfully
</p>
</div>
)}
{updateMutation.isError && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">
Failed to save settings. Please try again.
</p>
</div>
)}
{fetchNowMutation.isSuccess && (
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<p className="text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
{fetchNowMutation.data?.message}
</p>
</div>
)}
</div>
</div>
</div>
);
};
const StripeSettingsTab: React.FC = () => {
const { data: settings, isLoading, error } = usePlatformSettings();
const updateKeysMutation = useUpdateStripeKeys();

View File

@@ -121,6 +121,16 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
data.permissions = inviteForm.permissions;
}
// Only include limits if at least one is enabled (boolean true or numeric value set)
const hasLimits = Object.entries(inviteForm.limits).some(([key, value]) => {
if (typeof value === 'boolean') return value === true;
if (typeof value === 'number') return true; // numeric limits are meaningful even if 0
return false;
});
if (hasLimits) {
data.limits = inviteForm.limits;
}
if (inviteForm.personal_message.trim()) {
data.personal_message = inviteForm.personal_message.trim();
}
@@ -320,24 +330,21 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
</div>
</div>
{/* Feature Limits (Not Yet Implemented) */}
{/* Feature Limits & Capabilities */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Feature Limits & Capabilities
</label>
<span className="text-xs px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 rounded-full">
Coming Soon
</span>
</div>
<div className="space-y-3 opacity-50">
<div className="space-y-3">
{/* Video Conferencing */}
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={inviteForm.limits.can_add_video_conferencing}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_add_video_conferencing: e.target.checked } })}
className="rounded border-gray-300 dark:border-gray-600"
/>
Can add video conferencing to events
</label>
@@ -347,8 +354,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
<input
type="checkbox"
checked={inviteForm.limits.max_event_types === null}
disabled
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, max_event_types: e.target.checked ? null : 10 } })}
className="rounded border-gray-300 dark:border-gray-600 mt-1"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
@@ -357,10 +364,11 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
<input
type="number"
min="1"
disabled
disabled={inviteForm.limits.max_event_types === null}
value={inviteForm.limits.max_event_types || ''}
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, max_event_types: e.target.value ? parseInt(e.target.value) : null } })}
placeholder="Or set a limit"
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white cursor-not-allowed"
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
</div>
@@ -370,8 +378,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
<input
type="checkbox"
checked={inviteForm.limits.max_calendars_connected === null}
disabled
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, max_calendars_connected: e.target.checked ? null : 5 } })}
className="rounded border-gray-300 dark:border-gray-600 mt-1"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
@@ -380,10 +388,11 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
<input
type="number"
min="1"
disabled
disabled={inviteForm.limits.max_calendars_connected === null}
value={inviteForm.limits.max_calendars_connected || ''}
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, max_calendars_connected: e.target.value ? parseInt(e.target.value) : null } })}
placeholder="Or set a limit"
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white cursor-not-allowed"
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
</div>
@@ -393,8 +402,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
<input
type="checkbox"
checked={inviteForm.limits.can_connect_to_api}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_connect_to_api: e.target.checked } })}
className="rounded border-gray-300 dark:border-gray-600"
/>
Can connect to external APIs
</label>
@@ -404,8 +413,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
<input
type="checkbox"
checked={inviteForm.limits.can_book_repeated_events}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_book_repeated_events: e.target.checked } })}
className="rounded border-gray-300 dark:border-gray-600"
/>
Can book repeated/recurring events
</label>
@@ -415,8 +424,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
<input
type="checkbox"
checked={inviteForm.limits.can_require_2fa}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_require_2fa: e.target.checked } })}
className="rounded border-gray-300 dark:border-gray-600"
/>
Can require 2FA for users
</label>
@@ -426,8 +435,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
<input
type="checkbox"
checked={inviteForm.limits.can_download_logs}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_download_logs: e.target.checked } })}
className="rounded border-gray-300 dark:border-gray-600"
/>
Can download system logs
</label>
@@ -437,8 +446,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
<input
type="checkbox"
checked={inviteForm.limits.can_delete_data}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_delete_data: e.target.checked } })}
className="rounded border-gray-300 dark:border-gray-600"
/>
Can permanently delete data
</label>
@@ -448,8 +457,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
<input
type="checkbox"
checked={inviteForm.limits.can_use_masked_phone_numbers}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_use_masked_phone_numbers: e.target.checked } })}
className="rounded border-gray-300 dark:border-gray-600"
/>
Can use masked phone numbers for privacy
</label>
@@ -459,8 +468,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
<input
type="checkbox"
checked={inviteForm.limits.can_use_pos}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_use_pos: e.target.checked } })}
className="rounded border-gray-300 dark:border-gray-600"
/>
Can use Point of Sale (POS) system
</label>
@@ -470,8 +479,8 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
<input
type="checkbox"
checked={inviteForm.limits.can_use_mobile_app}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
onChange={(e) => setInviteForm({ ...inviteForm, limits: { ...inviteForm.limits, can_use_mobile_app: e.target.checked } })}
className="rounded border-gray-300 dark:border-gray-600"
/>
Can use mobile app
</label>

View File

@@ -233,6 +233,9 @@ export interface Ticket {
updatedAt: string; // Date string
resolvedAt?: string; // Date string
comments?: TicketComment[]; // Nested comments
// External sender info (for tickets from non-registered users via email)
externalEmail?: string;
externalName?: string;
}
export interface TicketTemplate {
@@ -341,4 +344,52 @@ export interface PluginInstallation {
hasUpdate: boolean;
rating?: number;
review?: string;
scheduledTaskId?: string;
}
// --- Email Template Types ---
export type EmailTemplateScope = 'BUSINESS' | 'PLATFORM';
export type EmailTemplateCategory =
| 'APPOINTMENT'
| 'REMINDER'
| 'CONFIRMATION'
| 'MARKETING'
| 'NOTIFICATION'
| 'REPORT'
| 'OTHER';
export interface EmailTemplate {
id: string;
name: string;
description: string;
subject: string;
htmlContent: string;
textContent: string;
scope: EmailTemplateScope;
isDefault: boolean;
category: EmailTemplateCategory;
previewContext?: Record<string, any>;
createdBy?: number;
createdByName?: string;
createdAt: string;
updatedAt: string;
}
export interface EmailTemplatePreview {
subject: string;
htmlContent: string;
textContent: string;
forceFooter: boolean;
}
export interface EmailTemplateVariable {
code: string;
description: string;
}
export interface EmailTemplateVariableGroup {
category: string;
items: EmailTemplateVariable[];
}

View File

@@ -12,3 +12,15 @@ REDIS_URL=redis://redis:6379/0
# Flower
CELERY_FLOWER_USER=aHPdcOatgRsYSHJThjUFyLTrzRXkiVsp
CELERY_FLOWER_PASSWORD=mH26NSH3PjskvgwrXplFvX1zFyIjl7O3Tqr9ddpbxd6zjceofepCcITJFVjS9ZwH
# Twilio (for SMS 2FA)
# ------------------------------------------------------------------------------
TWILIO_ACCOUNT_SID=AC10d7f7a218404da2219310918ec6f41b
TWILIO_AUTH_TOKEN=d6223df9fcd9ebd13cc64a3e20e01b3c
TWILIO_PHONE_NUMBER=
# Stripe (for payments)
# ------------------------------------------------------------------------------
STRIPE_PUBLISHABLE_KEY=pk_test_51SYttT4pb5kWPtNt8n52NRQLyBGbFQ52tnG1O5o11V06m3TPUyIzf6AHOpFNQErBj4m7pOwM6VzltePrdL16IFn0004YqWhRpA
STRIPE_SECRET_KEY=sk_test_51SYttT4pb5kWPtNtQUCOMFGHlkMRc88TYAuliEQdZsAb4Rs3mq1OJ4iS1ydQpSPYO3tmnZfm1y1tuMABq7188jsV00VVkfdD6q
STRIPE_WEBHOOK_SECRET=whsec_RH4ab9rFuBNdjw8LJ9IimHf1uVukCJIi

View File

@@ -0,0 +1,567 @@
# Email Template Generator Implementation Plan
## Overview
Create an email template system that allows both platform admins and business users to create reusable email templates. Templates can be attached to plugins via a new template variable type that prompts users to select from their email templates.
## Requirements Summary
1. **Dual access**: Available in both platform admin and business areas
2. **Both formats**: Support text and HTML email templates
3. **Visual preview**: Show how the final email will look
4. **Plugin integration**: Templates attachable via a new template tag type
5. **Separate templates**: Platform and business templates are completely separate
6. **Footer enforcement**: Free tier businesses must show "Powered by Smooth Schedule" footer (non-overridable)
7. **Editor modes**: Both visual builder AND code editor views
---
## Phase 1: Backend - EmailTemplate Model
### 1.1 Create EmailTemplate Model
**Location**: `schedule/models.py`
```python
class EmailTemplate(models.Model):
"""
Reusable email template for plugins and automations.
Supports both text and HTML content with template variable substitution.
"""
class Scope(models.TextChoices):
BUSINESS = 'BUSINESS', 'Business' # Tenant-specific
PLATFORM = 'PLATFORM', 'Platform' # Platform-wide (shared)
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
# Email structure
subject = models.CharField(max_length=500, help_text="Email subject line - supports template variables")
html_content = models.TextField(blank=True, help_text="HTML email body")
text_content = models.TextField(blank=True, help_text="Plain text email body")
# Scope
scope = models.CharField(
max_length=20,
choices=Scope.choices,
default=Scope.BUSINESS,
)
# Only for PLATFORM scope templates
is_default = models.BooleanField(default=False, help_text="Default template for certain triggers")
# Metadata
created_by = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
related_name='created_email_templates'
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Category for organization
category = models.CharField(
max_length=50,
choices=[
('APPOINTMENT', 'Appointment'),
('REMINDER', 'Reminder'),
('CONFIRMATION', 'Confirmation'),
('MARKETING', 'Marketing'),
('NOTIFICATION', 'Notification'),
('REPORT', 'Report'),
('OTHER', 'Other'),
],
default='OTHER'
)
# Preview data for visual preview
preview_context = models.JSONField(
default=dict,
blank=True,
help_text="Sample data for rendering preview"
)
class Meta:
ordering = ['name']
indexes = [
models.Index(fields=['scope', 'category']),
]
def __str__(self):
return f"{self.name} ({self.get_scope_display()})"
def render(self, context: dict, force_footer: bool = False) -> tuple[str, str, str]:
"""
Render the template with given context.
Args:
context: Dictionary of template variables
force_footer: If True, append "Powered by Smooth Schedule" footer
Returns:
Tuple of (subject, html_content, text_content)
"""
from .template_parser import TemplateVariableParser
subject = TemplateVariableParser.replace_insertion_codes(
self.subject, context
)
html = TemplateVariableParser.replace_insertion_codes(
self.html_content, context
) if self.html_content else ''
text = TemplateVariableParser.replace_insertion_codes(
self.text_content, context
) if self.text_content else ''
# Append footer for free tier if applicable
if force_footer:
html = self._append_html_footer(html)
text = self._append_text_footer(text)
return subject, html, text
def _append_html_footer(self, html: str) -> str:
"""Append Powered by Smooth Schedule footer to HTML"""
footer = '''
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #9ca3af; font-size: 12px;">
<p>
Powered by
<a href="https://smoothschedule.com" style="color: #6366f1; text-decoration: none; font-weight: 500;">
SmoothSchedule
</a>
</p>
</div>
'''
# Insert before </body> if present, otherwise append
if '</body>' in html.lower():
import re
return re.sub(r'(</body>)', footer + r'\1', html, flags=re.IGNORECASE)
return html + footer
def _append_text_footer(self, text: str) -> str:
"""Append Powered by Smooth Schedule footer to plain text"""
footer = "\n\n---\nPowered by SmoothSchedule - https://smoothschedule.com"
return text + footer
```
### 1.2 Update TemplateVariableParser
**Location**: `schedule/template_parser.py`
Add new variable type `email_template`:
```python
# Add to VARIABLE_PATTERN handling
# Format: {{PROMPT:variable_name|description||email_template}}
# When type == 'email_template', the frontend will:
# 1. Fetch available email templates from /api/email-templates/
# 2. Show a dropdown selector
# 3. Store the selected template ID in config_values
```
### 1.3 Create Serializers
**Location**: `schedule/serializers.py`
```python
class EmailTemplateSerializer(serializers.ModelSerializer):
"""Full serializer for CRUD operations"""
created_by_name = serializers.CharField(source='created_by.full_name', read_only=True)
class Meta:
model = EmailTemplate
fields = [
'id', 'name', 'description', 'subject',
'html_content', 'text_content', 'scope',
'is_default', 'category', 'preview_context',
'created_by', 'created_by_name',
'created_at', 'updated_at',
]
read_only_fields = ['created_at', 'updated_at', 'created_by']
class EmailTemplateListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for dropdowns"""
class Meta:
model = EmailTemplate
fields = ['id', 'name', 'description', 'category', 'scope']
class EmailTemplatePreviewSerializer(serializers.Serializer):
"""Serializer for preview endpoint"""
subject = serializers.CharField()
html_content = serializers.CharField(allow_blank=True)
text_content = serializers.CharField(allow_blank=True)
context = serializers.DictField(required=False, default=dict)
```
### 1.4 Create ViewSet
**Location**: `schedule/views.py`
```python
class EmailTemplateViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing email templates.
- Business users see only BUSINESS scope templates
- Platform users can also see/create PLATFORM scope templates
"""
serializer_class = EmailTemplateSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
user = self.request.user
# Platform users see all templates
if user.is_platform_user:
scope = self.request.query_params.get('scope')
if scope:
return EmailTemplate.objects.filter(scope=scope.upper())
return EmailTemplate.objects.all()
# Business users only see BUSINESS scope templates
return EmailTemplate.objects.filter(scope=EmailTemplate.Scope.BUSINESS)
def perform_create(self, serializer):
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"""
serializer = EmailTemplatePreviewSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
from .template_parser import TemplateVariableParser
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
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)
rendered_text = TemplateVariableParser.replace_insertion_codes(text, default_context)
# Check if free tier - append footer
force_footer = False
if not request.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:
rendered_html = EmailTemplate._append_html_footer(None, rendered_html)
rendered_text = EmailTemplate._append_text_footer(None, 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"""
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,
)
return Response(EmailTemplateSerializer(new_template).data, status=201)
```
---
## Phase 2: Plugin Integration
### 2.1 Update Template Parser for email_template Type
**Location**: `schedule/template_parser.py`
```python
# In extract_variables method, when type == 'email_template':
# Return special metadata to indicate dropdown
@classmethod
def _infer_type(cls, var_name: str, description: str) -> str:
# ... existing logic ...
# Check for explicit email_template type
# This is handled in the main extraction logic
pass
```
### 2.2 Update Plugin Execution to Handle Email Templates
**Location**: `schedule/tasks.py`
```python
def execute_plugin_with_email(plugin_code: str, config_values: dict, context: dict):
"""
Execute plugin code with email template support.
When config_values contains an email_template_id, load and render it.
"""
# Check for email template references in config
for key, value in config_values.items():
if key.endswith('_email_template') and isinstance(value, int):
# Load the email template
try:
template = EmailTemplate.objects.get(id=value)
# Render and make available in context
subject, html, text = template.render(context)
context[f'{key}_subject'] = subject
context[f'{key}_html'] = html
context[f'{key}_text'] = text
except EmailTemplate.DoesNotExist:
pass
# Continue with normal execution
# ...
```
---
## Phase 3: Frontend - Email Template Editor
### 3.1 Create EmailTemplateEditor Component
**Location**: `frontend/src/pages/EmailTemplates.tsx`
Main page with:
- List of templates with search/filter
- Create/Edit modal with dual-mode editor
- Preview panel
### 3.2 Create EmailTemplateForm Component
**Location**: `frontend/src/components/EmailTemplateForm.tsx`
Features:
1. **Subject line editor** - Simple text input with variable insertion
2. **Content editor** - Tabbed interface:
- **Visual mode**: WYSIWYG editor (TipTap or similar)
- **Code mode**: Monaco/CodeMirror for raw HTML
3. **Plain text editor** - Textarea with variable insertion buttons
4. **Preview panel** - Live rendering of HTML email
### 3.3 Variable Insertion Toolbar
Available variables shown as clickable chips:
- `{{BUSINESS_NAME}}`
- `{{BUSINESS_EMAIL}}`
- `{{CUSTOMER_NAME}}`
- `{{APPOINTMENT_TIME}}`
- etc.
### 3.4 Preview Component
**Location**: `frontend/src/components/EmailPreview.tsx`
- Desktop/mobile toggle
- Light/dark mode preview
- Sample data editor
- Footer preview (shown for free tier)
### 3.5 Update Plugin Config Form
**Location**: `frontend/src/pages/MyPlugins.tsx`
When `variable.type === 'email_template'`:
```tsx
<EmailTemplateSelector
value={configValues[key]}
onChange={(templateId) => setConfigValues({ ...configValues, [key]: templateId })}
scope="BUSINESS" // or "PLATFORM" for platform admin
/>
```
### 3.6 EmailTemplateSelector Component
**Location**: `frontend/src/components/EmailTemplateSelector.tsx`
- Dropdown showing available templates
- Category filtering
- Quick preview on hover
- "Create New" link
---
## Phase 4: Footer Enforcement
### 4.1 Backend Enforcement
In `EmailTemplate.render()` and preview endpoint:
- Check tenant subscription tier
- If `FREE`, always append footer regardless of template content
- Footer cannot be removed via template editing
### 4.2 Frontend Enforcement
In EmailTemplateForm:
- Show permanent footer preview for free tier
- Disable footer editing for free tier
- Show upgrade prompt: "Upgrade to remove footer"
---
## Phase 5: Platform Admin Features
### 5.1 Platform Email Templates Page
**Location**: `frontend/src/pages/platform/PlatformEmailTemplates.tsx`
- Create/manage PLATFORM scope templates
- Set default templates for system events
- Preview with tenant context
### 5.2 Default Templates
Create seed data for common templates:
- Tenant invitation email (already exists)
- Appointment reminder
- Appointment confirmation
- Password reset
- Welcome email
---
## Database Migrations
```python
# schedule/migrations/XXXX_email_template.py
class Migration(migrations.Migration):
dependencies = [
('schedule', 'previous_migration'),
]
operations = [
migrations.CreateModel(
name='EmailTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True)),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('subject', models.CharField(max_length=500)),
('html_content', models.TextField(blank=True)),
('text_content', models.TextField(blank=True)),
('scope', models.CharField(max_length=20, choices=[
('BUSINESS', 'Business'),
('PLATFORM', 'Platform'),
], default='BUSINESS')),
('is_default', models.BooleanField(default=False)),
('category', models.CharField(max_length=50, choices=[
('APPOINTMENT', 'Appointment'),
('REMINDER', 'Reminder'),
('CONFIRMATION', 'Confirmation'),
('MARKETING', 'Marketing'),
('NOTIFICATION', 'Notification'),
('REPORT', 'Report'),
('OTHER', 'Other'),
], default='OTHER')),
('preview_context', models.JSONField(default=dict, blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='created_email_templates',
to='users.user'
)),
],
options={
'ordering': ['name'],
},
),
migrations.AddIndex(
model_name='emailtemplate',
index=models.Index(fields=['scope', 'category'], name='schedule_em_scope_123456_idx'),
),
]
```
---
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/email-templates/` | List templates (filtered by scope) |
| 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 |
| POST | `/api/email-templates/{id}/duplicate/` | Duplicate template |
---
## Frontend Routes
| Route | Component | Description |
|-------|-----------|-------------|
| `/email-templates` | EmailTemplates | Business email templates |
| `/email-templates/create` | EmailTemplateForm | Create new template |
| `/email-templates/:id/edit` | EmailTemplateForm | Edit existing template |
| `/platform/email-templates` | PlatformEmailTemplates | Platform templates |
---
## Implementation Order
1. **Backend Model & Migration** - EmailTemplate model
2. **Backend API** - Serializers, ViewSet, URLs
3. **Frontend List Page** - Basic CRUD
4. **Frontend Editor** - Dual-mode editor
5. **Preview Component** - Live preview
6. **Plugin Integration** - email_template variable type
7. **Footer Enforcement** - Free tier logic
8. **Platform Admin** - Platform templates page
9. **Seed Data** - Default templates
---
## Testing Checklist
- [ ] Create business email template
- [ ] Create platform email template (as superuser)
- [ ] Preview template with sample data
- [ ] Verify footer appears for free tier
- [ ] Verify footer hidden for paid tiers
- [ ] Verify footer appears in preview for free tier
- [ ] Edit template in visual mode
- [ ] Edit template in code mode
- [ ] Use template in plugin configuration
- [ ] Send actual email using template
- [ ] Duplicate template
- [ ] Delete template

View File

@@ -99,6 +99,80 @@ Global registry for managing plugins.
- `list_all()` - List all plugins with metadata
- `list_by_category()` - Group plugins by category
#### Template Variables (`schedule/template_parser.py`)
Template variables allow plugins to define configurable fields that users can fill in via the UI.
**Variable Format:**
```
{{PROMPT:variable_name|description}}
{{PROMPT:variable_name|description|default_value}}
{{PROMPT:variable_name|description|default_value|type}}
{{PROMPT:variable_name|description||type}} (no default, explicit type)
```
**Supported Types:**
| Type | Description | UI Component |
|------|-------------|--------------|
| `text` | Single-line text input | Text input |
| `textarea` | Multi-line text input | Textarea |
| `email` | Email address | Email input with validation |
| `number` | Numeric value | Number input |
| `url` | URL/webhook endpoint | URL input with validation |
| `email_template` | Email template selector | Dropdown of email templates |
**Type Inference:**
If no explicit type is provided, the parser infers type from the variable name:
- Names containing `email``email` type
- Names containing `count`, `days`, `hours`, `amount``number` type
- Names containing `url`, `webhook`, `endpoint``url` type
- Names containing `message`, `body`, `content``textarea` type
- Default → `text` type
**Example - Email Template Variable:**
```python
# In your plugin script, reference an email template:
template_id = {{PROMPT:confirmation_template|Email template for confirmations||email_template}}
# The UI will show a dropdown of all available email templates
# The user selects a template, and the template ID is stored in the config
```
**Using Email Templates in Plugins:**
```python
from schedule.models import EmailTemplate
class MyNotificationPlugin(BasePlugin):
def execute(self, context):
template_id = self.config.get('confirmation_template')
if template_id:
template = EmailTemplate.objects.get(id=template_id)
subject, html, text = template.render({
'BUSINESS_NAME': context['business'].name,
'CUSTOMER_NAME': customer.name,
# ... other variables
})
# Send the email using the rendered template
send_email(recipient, subject, html, text)
```
**Insertion Codes (for use within templates):**
These codes are replaced at runtime with actual values:
- `{{BUSINESS_NAME}}` - Business name
- `{{BUSINESS_EMAIL}}` - Business contact email
- `{{BUSINESS_PHONE}}` - Business phone number
- `{{CUSTOMER_NAME}}` - Customer's name
- `{{CUSTOMER_EMAIL}}` - Customer's email
- `{{APPOINTMENT_TIME}}` - Appointment date/time
- `{{APPOINTMENT_DATE}}` - Appointment date only
- `{{APPOINTMENT_SERVICE}}` - Service name
- `{{TODAY}}` - Today's date
- `{{NOW}}` - Current date and time
### 3. Celery Tasks (`schedule/tasks.py`)
#### execute_scheduled_task(scheduled_task_id)
@@ -298,6 +372,11 @@ class MyCustomPlugin(BasePlugin):
'default': 100,
'description': 'Processing threshold',
},
'notification_template': {
'type': 'email_template',
'required': False,
'description': 'Email template to use for notifications',
},
}
def execute(self, context):

View File

@@ -333,3 +333,23 @@ CHANNEL_LAYERS = {
# Your stuff...
# ------------------------------------------------------------------------------
# Twilio (for SMS 2FA)
# ------------------------------------------------------------------------------
TWILIO_ACCOUNT_SID = env("TWILIO_ACCOUNT_SID", default="")
TWILIO_AUTH_TOKEN = env("TWILIO_AUTH_TOKEN", default="")
TWILIO_PHONE_NUMBER = env("TWILIO_PHONE_NUMBER", default="")
# Stripe (for payments)
# ------------------------------------------------------------------------------
STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", default="")
STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", default="")
STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", default="")
# dj-stripe configuration
STRIPE_LIVE_SECRET_KEY = env("STRIPE_SECRET_KEY", default="")
STRIPE_TEST_SECRET_KEY = env("STRIPE_SECRET_KEY", default="")
STRIPE_LIVE_MODE = env.bool("STRIPE_LIVE_MODE", default=False)
DJSTRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", default="")
DJSTRIPE_USE_NATIVE_JSONFIELD = True
DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id"

View File

@@ -43,6 +43,7 @@ SHARED_APPS = [
'crispy_forms',
'crispy_bootstrap5',
'csp',
'djstripe', # Stripe integration
'tickets', # Ticket system - shared for platform support access
'smoothschedule.public_api', # Public API v1 for third-party integrations
]

View File

@@ -11,11 +11,17 @@ from drf_spectacular.views import SpectacularSwaggerView
from rest_framework.authtoken.views import obtain_auth_token
from smoothschedule.users.api_views import (
current_user_view, logout_view, send_verification_email, verify_email,
login_view, current_user_view, logout_view, send_verification_email, verify_email,
hijack_acquire_view, hijack_release_view,
staff_invitations_view, cancel_invitation_view, resend_invitation_view,
invitation_details_view, accept_invitation_view, decline_invitation_view
)
from smoothschedule.users.mfa_api_views import (
mfa_status, send_phone_verification, verify_phone, enable_sms_mfa,
setup_totp, verify_totp_setup, generate_backup_codes, backup_codes_status,
disable_mfa, mfa_login_send_code, mfa_login_verify,
list_trusted_devices, revoke_trusted_device, revoke_all_trusted_devices
)
from schedule.api_views import (
current_business_view, update_business_view,
oauth_settings_view, oauth_credentials_view,
@@ -40,6 +46,9 @@ urlpatterns = [
# API URLS
urlpatterns += [
# Stripe Webhooks (dj-stripe built-in handler)
# This is the URL you register with Stripe: https://yourdomain.com/api/stripe/webhook/
path("api/stripe/", include("djstripe.urls", namespace="djstripe")),
# Public API v1 (for third-party integrations)
path("api/v1/", include("smoothschedule.public_api.urls", namespace="public_api")),
# Schedule API (internal)
@@ -54,6 +63,7 @@ urlpatterns += [
path("api/platform/", include("platform_admin.urls", namespace="platform")),
# Auth API
path("api/auth-token/", csrf_exempt(obtain_auth_token), name="obtain_auth_token"),
path("api/auth/login/", login_view, name="login"),
path("api/auth/me/", current_user_view, name="current_user"),
path("api/auth/logout/", logout_view, name="logout"),
path("api/auth/email/verify/send/", send_verification_email, name="send_verification_email"),
@@ -82,6 +92,21 @@ urlpatterns += [
path("api/sandbox/status/", sandbox_status_view, name="sandbox_status"),
path("api/sandbox/toggle/", sandbox_toggle_view, name="sandbox_toggle"),
path("api/sandbox/reset/", sandbox_reset_view, name="sandbox_reset"),
# MFA (Two-Factor Authentication) API
path("api/auth/mfa/status/", mfa_status, name="mfa_status"),
path("api/auth/mfa/phone/send/", send_phone_verification, name="mfa_phone_send"),
path("api/auth/mfa/phone/verify/", verify_phone, name="mfa_phone_verify"),
path("api/auth/mfa/sms/enable/", enable_sms_mfa, name="mfa_sms_enable"),
path("api/auth/mfa/totp/setup/", setup_totp, name="mfa_totp_setup"),
path("api/auth/mfa/totp/verify/", verify_totp_setup, name="mfa_totp_verify"),
path("api/auth/mfa/backup-codes/", generate_backup_codes, name="mfa_backup_codes"),
path("api/auth/mfa/backup-codes/status/", backup_codes_status, name="mfa_backup_codes_status"),
path("api/auth/mfa/disable/", disable_mfa, name="mfa_disable"),
path("api/auth/mfa/login/send/", mfa_login_send_code, name="mfa_login_send"),
path("api/auth/mfa/login/verify/", mfa_login_verify, name="mfa_login_verify"),
path("api/auth/mfa/devices/", list_trusted_devices, name="mfa_devices_list"),
path("api/auth/mfa/devices/<int:device_id>/", revoke_trusted_device, name="mfa_device_revoke"),
path("api/auth/mfa/devices/revoke-all/", revoke_all_trusted_devices, name="mfa_devices_revoke_all"),
# API Docs
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
path(

View File

@@ -0,0 +1,68 @@
# Generated by Django 5.2.8 on 2025-11-29 18:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_add_sandbox_fields'),
]
operations = [
migrations.AddField(
model_name='tenant',
name='can_add_video_conferencing',
field=models.BooleanField(default=False, help_text='Whether this business can add video conferencing to events'),
),
migrations.AddField(
model_name='tenant',
name='can_book_repeated_events',
field=models.BooleanField(default=True, help_text='Whether this business can book repeated/recurring events'),
),
migrations.AddField(
model_name='tenant',
name='can_connect_to_api',
field=models.BooleanField(default=False, help_text='Whether this business can connect to external APIs'),
),
migrations.AddField(
model_name='tenant',
name='can_delete_data',
field=models.BooleanField(default=False, help_text='Whether this business can permanently delete data'),
),
migrations.AddField(
model_name='tenant',
name='can_download_logs',
field=models.BooleanField(default=False, help_text='Whether this business can download system logs'),
),
migrations.AddField(
model_name='tenant',
name='can_require_2fa',
field=models.BooleanField(default=False, help_text='Whether this business can require 2FA for users'),
),
migrations.AddField(
model_name='tenant',
name='can_use_masked_phone_numbers',
field=models.BooleanField(default=False, help_text='Whether this business can use masked phone numbers for privacy'),
),
migrations.AddField(
model_name='tenant',
name='can_use_mobile_app',
field=models.BooleanField(default=False, help_text='Whether this business can use the mobile app'),
),
migrations.AddField(
model_name='tenant',
name='can_use_pos',
field=models.BooleanField(default=False, help_text='Whether this business can use Point of Sale (POS) system'),
),
migrations.AddField(
model_name='tenant',
name='max_calendars_connected',
field=models.IntegerField(blank=True, help_text='Maximum number of external calendars connected (null = unlimited)', null=True),
),
migrations.AddField(
model_name='tenant',
name='max_event_types',
field=models.IntegerField(blank=True, help_text='Maximum number of event types (null = unlimited)', null=True),
),
]

View File

@@ -120,6 +120,54 @@ class Tenant(TenantMixin):
help_text="Whether this business can access the API for integrations"
)
# Feature Limits & Capabilities (set by platform admins via tenant invitation)
can_add_video_conferencing = models.BooleanField(
default=False,
help_text="Whether this business can add video conferencing to events"
)
max_event_types = models.IntegerField(
null=True,
blank=True,
help_text="Maximum number of event types (null = unlimited)"
)
max_calendars_connected = models.IntegerField(
null=True,
blank=True,
help_text="Maximum number of external calendars connected (null = unlimited)"
)
can_connect_to_api = models.BooleanField(
default=False,
help_text="Whether this business can connect to external APIs"
)
can_book_repeated_events = models.BooleanField(
default=True,
help_text="Whether this business can book repeated/recurring events"
)
can_require_2fa = models.BooleanField(
default=False,
help_text="Whether this business can require 2FA for users"
)
can_download_logs = models.BooleanField(
default=False,
help_text="Whether this business can download system logs"
)
can_delete_data = models.BooleanField(
default=False,
help_text="Whether this business can permanently delete data"
)
can_use_masked_phone_numbers = models.BooleanField(
default=False,
help_text="Whether this business can use masked phone numbers for privacy"
)
can_use_pos = models.BooleanField(
default=False,
help_text="Whether this business can use Point of Sale (POS) system"
)
can_use_mobile_app = models.BooleanField(
default=False,
help_text="Whether this business can use the mobile app"
)
# Onboarding tracking
initial_setup_complete = models.BooleanField(
default=False,

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-11-29 18:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('platform_admin', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='tenantinvitation',
name='limits',
field=models.JSONField(blank=True, default=dict, help_text='Feature limits and capabilities to grant'),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.8 on 2025-11-29 21:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('platform_admin', '0002_add_limits_field'),
]
operations = [
migrations.CreateModel(
name='PlatformSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_secret_key', models.CharField(blank=True, default='', help_text='Stripe secret key (overrides env if set)', max_length=255)),
('stripe_publishable_key', models.CharField(blank=True, default='', help_text='Stripe publishable key (overrides env if set)', max_length=255)),
('stripe_webhook_secret', models.CharField(blank=True, default='', help_text='Stripe webhook secret (overrides env if set)', max_length=255)),
('stripe_account_id', models.CharField(blank=True, default='', help_text='Stripe account ID (set after validation)', max_length=255)),
('stripe_account_name', models.CharField(blank=True, default='', help_text='Stripe account name (set after validation)', max_length=255)),
('stripe_keys_validated_at', models.DateTimeField(blank=True, help_text='When the Stripe keys were last validated', null=True)),
('stripe_validation_error', models.TextField(blank=True, default='', help_text='Last validation error, if any')),
('oauth_settings', models.JSONField(blank=True, default=dict, help_text='OAuth provider settings (Google, Apple, etc.)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Platform Settings',
'verbose_name_plural': 'Platform Settings',
},
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.2.8 on 2025-11-29 21:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('platform_admin', '0003_add_platform_settings'),
]
operations = [
migrations.CreateModel(
name='SubscriptionPlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True, default='')),
('plan_type', models.CharField(choices=[('base', 'Base Plan'), ('addon', 'Add-on')], default='base', max_length=10)),
('stripe_product_id', models.CharField(blank=True, default='', help_text='Stripe Product ID', max_length=255)),
('stripe_price_id', models.CharField(blank=True, default='', help_text='Stripe Price ID (for monthly billing)', max_length=255)),
('price_monthly', models.DecimalField(blank=True, decimal_places=2, help_text='Monthly price in dollars', max_digits=10, null=True)),
('price_yearly', models.DecimalField(blank=True, decimal_places=2, help_text='Yearly price in dollars', max_digits=10, null=True)),
('business_tier', models.CharField(choices=[('FREE', 'Free'), ('STARTER', 'Starter'), ('PROFESSIONAL', 'Professional'), ('ENTERPRISE', 'Enterprise')], default='PROFESSIONAL', max_length=50)),
('features', models.JSONField(blank=True, default=list, help_text='List of feature descriptions')),
('transaction_fee_percent', models.DecimalField(decimal_places=2, default=2.9, help_text='Transaction fee percentage', max_digits=5)),
('transaction_fee_fixed', models.DecimalField(decimal_places=2, default=0.3, help_text='Fixed transaction fee in dollars', max_digits=5)),
('is_active', models.BooleanField(default=True)),
('is_public', models.BooleanField(default=True, help_text='Whether this plan is visible on public pricing page')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['price_monthly', 'name'],
},
),
]

View File

@@ -7,6 +7,248 @@ from datetime import timedelta
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.core.cache import cache
class PlatformSettings(models.Model):
"""
Singleton model for platform-wide settings.
Settings like Stripe keys can be overridden via environment variables.
"""
# Stripe settings (can be overridden or set via admin)
stripe_secret_key = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe secret key (overrides env if set)"
)
stripe_publishable_key = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe publishable key (overrides env if set)"
)
stripe_webhook_secret = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe webhook secret (overrides env if set)"
)
# Stripe validation info
stripe_account_id = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe account ID (set after validation)"
)
stripe_account_name = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe account name (set after validation)"
)
stripe_keys_validated_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the Stripe keys were last validated"
)
stripe_validation_error = models.TextField(
blank=True,
default='',
help_text="Last validation error, if any"
)
# OAuth settings - stored as JSON for flexibility
oauth_settings = models.JSONField(
default=dict,
blank=True,
help_text="OAuth provider settings (Google, Apple, etc.)"
)
# Example oauth_settings structure:
# {
# "allow_registration": true,
# "google": {"enabled": true, "client_id": "...", "client_secret": "..."},
# "apple": {"enabled": false, "client_id": "", ...},
# ...
# }
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'platform_admin'
verbose_name = 'Platform Settings'
verbose_name_plural = 'Platform Settings'
def __str__(self):
return "Platform Settings"
def save(self, *args, **kwargs):
# Ensure only one instance exists
self.pk = 1
super().save(*args, **kwargs)
# Clear cache
cache.delete('platform_settings')
def delete(self, *args, **kwargs):
# Prevent deletion
pass
@classmethod
def get_instance(cls):
"""Get or create the singleton instance"""
# Try cache first
cached = cache.get('platform_settings')
if cached:
return cached
instance, _ = cls.objects.get_or_create(pk=1)
cache.set('platform_settings', instance, timeout=300) # 5 min cache
return instance
def get_stripe_secret_key(self):
"""Get Stripe secret key (database or env)"""
from django.conf import settings
if self.stripe_secret_key:
return self.stripe_secret_key
return getattr(settings, 'STRIPE_SECRET_KEY', '')
def get_stripe_publishable_key(self):
"""Get Stripe publishable key (database or env)"""
from django.conf import settings
if self.stripe_publishable_key:
return self.stripe_publishable_key
return getattr(settings, 'STRIPE_PUBLISHABLE_KEY', '')
def get_stripe_webhook_secret(self):
"""Get Stripe webhook secret (database or env)"""
from django.conf import settings
if self.stripe_webhook_secret:
return self.stripe_webhook_secret
return getattr(settings, 'STRIPE_WEBHOOK_SECRET', '')
def has_stripe_keys(self):
"""Check if Stripe keys are configured"""
return bool(self.get_stripe_secret_key() and self.get_stripe_publishable_key())
def stripe_keys_from_env(self):
"""Check if Stripe keys come from environment"""
from django.conf import settings
return (
not self.stripe_secret_key and
bool(getattr(settings, 'STRIPE_SECRET_KEY', ''))
)
def mask_key(self, key, visible_chars=8):
"""Mask a key, showing only first/last chars"""
if not key:
return ''
if len(key) <= visible_chars * 2:
return '*' * len(key)
return f"{key[:visible_chars]}...{key[-4:]}"
class SubscriptionPlan(models.Model):
"""
Subscription plans that can be assigned to tenants.
Links to Stripe products/prices for billing.
"""
class PlanType(models.TextChoices):
BASE = 'base', _('Base Plan')
ADDON = 'addon', _('Add-on')
name = models.CharField(max_length=100)
description = models.TextField(blank=True, default='')
plan_type = models.CharField(
max_length=10,
choices=PlanType.choices,
default=PlanType.BASE
)
# Stripe integration
stripe_product_id = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe Product ID"
)
stripe_price_id = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe Price ID (for monthly billing)"
)
# Pricing
price_monthly = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
help_text="Monthly price in dollars"
)
price_yearly = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
help_text="Yearly price in dollars"
)
# Business tier this plan corresponds to
business_tier = models.CharField(
max_length=50,
choices=[
('FREE', 'Free'),
('STARTER', 'Starter'),
('PROFESSIONAL', 'Professional'),
('ENTERPRISE', 'Enterprise'),
],
default='PROFESSIONAL'
)
# Features included (stored as JSON array of strings)
features = models.JSONField(
default=list,
blank=True,
help_text="List of feature descriptions"
)
# Transaction fees for payment processing
transaction_fee_percent = models.DecimalField(
max_digits=5,
decimal_places=2,
default=2.9,
help_text="Transaction fee percentage"
)
transaction_fee_fixed = models.DecimalField(
max_digits=5,
decimal_places=2,
default=0.30,
help_text="Fixed transaction fee in dollars"
)
# Visibility
is_active = models.BooleanField(default=True)
is_public = models.BooleanField(
default=True,
help_text="Whether this plan is visible on public pricing page"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'platform_admin'
ordering = ['price_monthly', 'name']
def __str__(self):
return f"{self.name} ({self.get_plan_type_display()})"
class TenantInvitation(models.Model):
@@ -83,6 +325,27 @@ class TenantInvitation(models.Model):
# "can_api_access": true,
# }
# Feature limits (what capabilities this tenant has)
limits = models.JSONField(
default=dict,
blank=True,
help_text="Feature limits and capabilities to grant"
)
# Example limits structure:
# {
# "can_add_video_conferencing": true,
# "max_event_types": 10, # null = unlimited
# "max_calendars_connected": 5, # null = unlimited
# "can_connect_to_api": true,
# "can_book_repeated_events": true,
# "can_require_2fa": true,
# "can_download_logs": false,
# "can_delete_data": false,
# "can_use_masked_phone_numbers": false,
# "can_use_pos": false,
# "can_use_mobile_app": true,
# }
# Personal message to include in email
personal_message = models.TextField(
blank=True,
@@ -192,7 +455,7 @@ class TenantInvitation(models.Model):
@classmethod
def create_invitation(cls, email, invited_by, subscription_tier='PROFESSIONAL',
suggested_business_name='', custom_max_users=None,
custom_max_resources=None, permissions=None,
custom_max_resources=None, permissions=None, limits=None,
personal_message=''):
"""
Create a new tenant invitation, cancelling any existing pending invitations
@@ -213,5 +476,6 @@ class TenantInvitation(models.Model):
custom_max_users=custom_max_users,
custom_max_resources=custom_max_resources,
permissions=permissions or {},
limits=limits or {},
personal_message=personal_message,
)

View File

@@ -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)

View File

@@ -0,0 +1,209 @@
"""
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}

View File

@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>You're Invited to SmoothSchedule</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: #6366f1;
}
h1 {
color: #1f2937;
font-size: 24px;
margin-bottom: 20px;
}
p {
color: #4b5563;
margin-bottom: 15px;
}
.personal-message {
background-color: #f3f4f6;
border-left: 4px solid #6366f1;
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 8px 8px 0;
font-style: italic;
}
.cta-button {
display: inline-block;
background-color: #6366f1;
color: #ffffff !important;
text-decoration: none;
padding: 14px 32px;
border-radius: 8px;
font-weight: 600;
font-size: 16px;
margin: 20px 0;
}
.cta-button:hover {
background-color: #4f46e5;
}
.cta-container {
text-align: center;
margin: 30px 0;
}
.details {
background-color: #fafafa;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.details-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #e5e7eb;
}
.details-row:last-child {
border-bottom: none;
}
.details-label {
color: #6b7280;
font-size: 14px;
}
.details-value {
color: #1f2937;
font-weight: 500;
}
.footer {
text-align: center;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
color: #9ca3af;
font-size: 14px;
}
.link-fallback {
font-size: 12px;
color: #6b7280;
word-break: break-all;
margin-top: 20px;
}
.expire-notice {
color: #dc2626;
font-size: 14px;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">SmoothSchedule</div>
</div>
<h1>You're Invited!</h1>
<p>Hi there,</p>
<p>
<strong>{{ inviter_name }}</strong> has invited you to create your business on SmoothSchedule,
the modern scheduling platform that helps you manage appointments, staff, and customers effortlessly.
</p>
{% if personal_message %}
<div class="personal-message">
"{{ personal_message }}"
<br><br>
<small>— {{ inviter_name }}</small>
</div>
{% endif %}
<div class="details">
<div class="details-row">
<span class="details-label">Suggested Business Name</span>
<span class="details-value">{{ suggested_business_name }}</span>
</div>
<div class="details-row">
<span class="details-label">Subscription Plan</span>
<span class="details-value">{{ invitation.get_subscription_tier_display }}</span>
</div>
</div>
<div class="cta-container">
<a href="{{ invitation_url }}" class="cta-button">Accept Invitation & Get Started</a>
</div>
<p class="link-fallback">
If the button doesn't work, copy and paste this link into your browser:<br>
{{ invitation_url }}
</p>
<p class="expire-notice">
This invitation expires on {{ expires_at|date:"F j, Y" }} at {{ expires_at|time:"g:i A" }}.
</p>
<div class="footer">
<p>
This email was sent by SmoothSchedule.<br>
If you didn't expect this invitation, you can safely ignore this email.
</p>
<p>&copy; {% now "Y" %} SmoothSchedule. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -3,7 +3,19 @@ Platform URL Configuration
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import TenantViewSet, PlatformUserViewSet, TenantInvitationViewSet
from .views import (
TenantViewSet,
PlatformUserViewSet,
TenantInvitationViewSet,
SubscriptionPlanViewSet,
PlatformSettingsView,
StripeKeysView,
StripeValidateView,
StripeWebhooksView,
StripeWebhookDetailView,
StripeWebhookRotateSecretView,
OAuthSettingsView,
)
app_name = 'platform'
@@ -11,9 +23,22 @@ router = DefaultRouter()
router.register(r'businesses', TenantViewSet, basename='business')
router.register(r'users', PlatformUserViewSet, basename='user')
router.register(r'tenant-invitations', TenantInvitationViewSet, basename='tenant-invitation')
router.register(r'subscription-plans', SubscriptionPlanViewSet, basename='subscription-plan')
urlpatterns = [
path('', include(router.urls)),
# Platform settings endpoints
path('settings/', PlatformSettingsView.as_view(), name='settings'),
path('settings/stripe/keys/', StripeKeysView.as_view(), name='stripe-keys'),
path('settings/stripe/validate/', StripeValidateView.as_view(), name='stripe-validate'),
path('settings/oauth/', OAuthSettingsView.as_view(), name='oauth-settings'),
# Stripe webhook management
path('settings/stripe/webhooks/', StripeWebhooksView.as_view(), name='stripe-webhooks'),
path('settings/stripe/webhooks/<str:webhook_id>/', StripeWebhookDetailView.as_view(), name='stripe-webhook-detail'),
path('settings/stripe/webhooks/<str:webhook_id>/rotate-secret/', StripeWebhookRotateSecretView.as_view(), name='stripe-webhook-rotate-secret'),
# Public endpoints for tenant invitations
path(
'tenant-invitations/token/<str:token>/',

View File

@@ -9,6 +9,7 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from django.db.models import Count
from django.db import transaction, connection
from django.utils import timezone
@@ -16,7 +17,7 @@ from django_tenants.utils import schema_context
from core.models import Tenant, Domain
from smoothschedule.users.models import User
from .models import TenantInvitation
from .models import TenantInvitation, PlatformSettings, SubscriptionPlan
from .serializers import (
TenantSerializer,
TenantCreateSerializer,
@@ -26,11 +27,706 @@ from .serializers import (
TenantInvitationSerializer,
TenantInvitationCreateSerializer,
TenantInvitationAcceptSerializer,
TenantInvitationDetailSerializer
TenantInvitationDetailSerializer,
PlatformSettingsSerializer,
StripeKeysUpdateSerializer,
OAuthSettingsSerializer,
OAuthSettingsResponseSerializer,
SubscriptionPlanSerializer,
SubscriptionPlanCreateSerializer,
)
from .permissions import IsPlatformAdmin, IsPlatformUser
class PlatformSettingsView(APIView):
"""
GET /api/platform/settings/
Get platform settings (Stripe config status, etc.)
"""
permission_classes = [IsAuthenticated, IsPlatformAdmin]
def get(self, request):
settings = PlatformSettings.get_instance()
serializer = PlatformSettingsSerializer(settings)
return Response(serializer.data)
class StripeKeysView(APIView):
"""
POST /api/platform/settings/stripe/keys/
Update Stripe API keys
"""
permission_classes = [IsAuthenticated, IsPlatformAdmin]
def post(self, request):
serializer = StripeKeysUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
settings = PlatformSettings.get_instance()
# Update keys if provided
if serializer.validated_data.get('stripe_secret_key'):
settings.stripe_secret_key = serializer.validated_data['stripe_secret_key']
if serializer.validated_data.get('stripe_publishable_key'):
settings.stripe_publishable_key = serializer.validated_data['stripe_publishable_key']
if serializer.validated_data.get('stripe_webhook_secret'):
settings.stripe_webhook_secret = serializer.validated_data['stripe_webhook_secret']
# Clear validation status when keys change
settings.stripe_keys_validated_at = None
settings.stripe_validation_error = ''
settings.stripe_account_id = ''
settings.stripe_account_name = ''
settings.save()
return Response(PlatformSettingsSerializer(settings).data)
class StripeValidateView(APIView):
"""
POST /api/platform/settings/stripe/validate/
Validate Stripe API keys by making a test API call
"""
permission_classes = [IsAuthenticated, IsPlatformAdmin]
def post(self, request):
settings = PlatformSettings.get_instance()
if not settings.has_stripe_keys():
return Response(
{'error': 'No Stripe keys configured'},
status=status.HTTP_400_BAD_REQUEST
)
try:
import stripe
stripe.api_key = settings.get_stripe_secret_key()
# Try to retrieve account info
account = stripe.Account.retrieve()
# Update settings with account info
settings.stripe_account_id = account.id
settings.stripe_account_name = account.get('business_profile', {}).get('name', '') or account.get('email', '')
settings.stripe_keys_validated_at = timezone.now()
settings.stripe_validation_error = ''
settings.save()
return Response({
'valid': True,
'account_id': settings.stripe_account_id,
'account_name': settings.stripe_account_name,
'settings': PlatformSettingsSerializer(settings).data
})
except stripe.error.AuthenticationError as e:
settings.stripe_validation_error = str(e)
settings.stripe_keys_validated_at = None
settings.save()
return Response({
'valid': False,
'error': 'Invalid API key',
'settings': PlatformSettingsSerializer(settings).data
}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
settings.stripe_validation_error = str(e)
settings.save()
return Response({
'valid': False,
'error': str(e),
'settings': PlatformSettingsSerializer(settings).data
}, status=status.HTTP_400_BAD_REQUEST)
class OAuthSettingsView(APIView):
"""
GET/POST /api/platform/settings/oauth/
Get or update OAuth provider settings
"""
permission_classes = [IsAuthenticated, IsPlatformAdmin]
PROVIDERS = ['google', 'apple', 'facebook', 'linkedin', 'microsoft', 'twitter', 'twitch']
def _mask_secret(self, secret):
"""Mask a secret string"""
if not secret:
return ''
if len(secret) <= 8:
return '*' * len(secret)
return f"{secret[:4]}...{secret[-4:]}"
def _format_provider_settings(self, provider, oauth_settings):
"""Format provider settings for response"""
prefix = f"{provider}"
settings_dict = oauth_settings.get(provider, {})
result = {
'enabled': settings_dict.get('enabled', False),
'client_id': settings_dict.get('client_id', ''),
'client_secret': self._mask_secret(settings_dict.get('client_secret', '')),
}
# Add provider-specific fields
if provider == 'apple':
result['team_id'] = settings_dict.get('team_id', '')
result['key_id'] = settings_dict.get('key_id', '')
elif provider == 'microsoft':
result['tenant_id'] = settings_dict.get('tenant_id', '')
return result
def get(self, request):
settings = PlatformSettings.get_instance()
oauth_settings = settings.oauth_settings or {}
response_data = {
'oauth_allow_registration': oauth_settings.get('allow_registration', True),
}
for provider in self.PROVIDERS:
response_data[provider] = self._format_provider_settings(provider, oauth_settings)
return Response(response_data)
def post(self, request):
serializer = OAuthSettingsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
settings = PlatformSettings.get_instance()
oauth_settings = settings.oauth_settings or {}
# Update allow_registration
if 'oauth_allow_registration' in serializer.validated_data:
oauth_settings['allow_registration'] = serializer.validated_data['oauth_allow_registration']
# Update each provider's settings
for provider in self.PROVIDERS:
if provider not in oauth_settings:
oauth_settings[provider] = {}
enabled_key = f'oauth_{provider}_enabled'
client_id_key = f'oauth_{provider}_client_id'
client_secret_key = f'oauth_{provider}_client_secret'
if enabled_key in serializer.validated_data:
oauth_settings[provider]['enabled'] = serializer.validated_data[enabled_key]
if client_id_key in serializer.validated_data:
oauth_settings[provider]['client_id'] = serializer.validated_data[client_id_key]
if client_secret_key in serializer.validated_data:
# Only update if not empty (don't overwrite with empty string)
if serializer.validated_data[client_secret_key]:
oauth_settings[provider]['client_secret'] = serializer.validated_data[client_secret_key]
# Provider-specific fields
if provider == 'apple':
if f'oauth_apple_team_id' in serializer.validated_data:
oauth_settings[provider]['team_id'] = serializer.validated_data['oauth_apple_team_id']
if f'oauth_apple_key_id' in serializer.validated_data:
oauth_settings[provider]['key_id'] = serializer.validated_data['oauth_apple_key_id']
elif provider == 'microsoft':
if f'oauth_microsoft_tenant_id' in serializer.validated_data:
oauth_settings[provider]['tenant_id'] = serializer.validated_data['oauth_microsoft_tenant_id']
settings.oauth_settings = oauth_settings
settings.save()
# Return updated settings
response_data = {
'oauth_allow_registration': oauth_settings.get('allow_registration', True),
}
for provider in self.PROVIDERS:
response_data[provider] = self._format_provider_settings(provider, oauth_settings)
return Response(response_data)
class StripeWebhooksView(APIView):
"""
GET /api/platform/settings/stripe/webhooks/
List all Stripe webhook endpoints
POST /api/platform/settings/stripe/webhooks/
Create a new webhook endpoint
"""
permission_classes = [IsAuthenticated, IsPlatformAdmin]
# Default events to subscribe to
DEFAULT_EVENTS = [
"checkout.session.completed",
"checkout.session.expired",
"customer.created",
"customer.updated",
"customer.deleted",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.subscription.trial_will_end",
"invoice.created",
"invoice.finalized",
"invoice.paid",
"invoice.payment_failed",
"invoice.payment_action_required",
"payment_intent.succeeded",
"payment_intent.payment_failed",
"payment_intent.canceled",
"payment_method.attached",
"payment_method.detached",
"account.updated", # For Connect
"account.application.authorized",
"account.application.deauthorized",
]
def _format_webhook(self, endpoint):
"""Format webhook endpoint for response"""
return {
'id': endpoint.id,
'url': endpoint.url,
'status': endpoint.status,
'enabled_events': endpoint.enabled_events,
'api_version': endpoint.api_version,
'created': endpoint.created.isoformat() if endpoint.created else None,
'livemode': endpoint.livemode,
# Don't expose the secret, just indicate if it exists
'has_secret': bool(endpoint.secret),
}
def get(self, request):
"""List all webhook endpoints from Stripe"""
import stripe
from djstripe.models import WebhookEndpoint
settings = PlatformSettings.get_instance()
if not settings.has_stripe_keys():
return Response(
{'error': 'Stripe keys not configured'},
status=status.HTTP_400_BAD_REQUEST
)
try:
stripe.api_key = settings.get_stripe_secret_key()
# Fetch from Stripe API
stripe_webhooks = stripe.WebhookEndpoint.list(limit=100)
# Sync to local database and format response
webhooks = []
for wh in stripe_webhooks.data:
# Sync to dj-stripe
local_wh = WebhookEndpoint.sync_from_stripe_data(wh)
webhooks.append(self._format_webhook(local_wh))
return Response({
'webhooks': webhooks,
'count': len(webhooks),
})
except stripe.error.AuthenticationError:
return Response(
{'error': 'Invalid Stripe API key'},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
def post(self, request):
"""Create a new webhook endpoint"""
import stripe
from djstripe.models import WebhookEndpoint
settings = PlatformSettings.get_instance()
if not settings.has_stripe_keys():
return Response(
{'error': 'Stripe keys not configured'},
status=status.HTTP_400_BAD_REQUEST
)
url = request.data.get('url')
if not url:
return Response(
{'error': 'URL is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Validate URL format
if not url.startswith('https://'):
return Response(
{'error': 'Webhook URL must use HTTPS'},
status=status.HTTP_400_BAD_REQUEST
)
enabled_events = request.data.get('enabled_events', self.DEFAULT_EVENTS)
description = request.data.get('description', 'SmoothSchedule Platform Webhook')
try:
stripe.api_key = settings.get_stripe_secret_key()
# Create webhook on Stripe
endpoint = stripe.WebhookEndpoint.create(
url=url,
enabled_events=enabled_events,
description=description,
metadata={'created_by': 'smoothschedule_platform'},
)
# The secret is only returned on creation - save it
webhook_secret = endpoint.secret
# Sync to local database
local_wh = WebhookEndpoint.sync_from_stripe_data(endpoint)
# Store the secret in local DB (it's not returned by Stripe after creation)
local_wh.secret = webhook_secret
local_wh.save()
# Also update platform settings if this is the primary webhook
if request.data.get('set_as_primary', False):
settings.stripe_webhook_secret = webhook_secret
settings.save()
return Response({
'webhook': self._format_webhook(local_wh),
'secret': webhook_secret, # Only returned on creation!
'message': 'Webhook created successfully. Save the secret - it will not be shown again.',
}, status=status.HTTP_201_CREATED)
except stripe.error.InvalidRequestError as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class StripeWebhookDetailView(APIView):
"""
GET /api/platform/settings/stripe/webhooks/<id>/
Get a specific webhook endpoint
PATCH /api/platform/settings/stripe/webhooks/<id>/
Update a webhook endpoint
DELETE /api/platform/settings/stripe/webhooks/<id>/
Delete a webhook endpoint
"""
permission_classes = [IsAuthenticated, IsPlatformAdmin]
def _format_webhook(self, endpoint):
"""Format webhook endpoint for response"""
return {
'id': endpoint.id,
'url': endpoint.url,
'status': endpoint.status,
'enabled_events': endpoint.enabled_events,
'api_version': endpoint.api_version,
'created': endpoint.created.isoformat() if endpoint.created else None,
'livemode': endpoint.livemode,
'has_secret': bool(endpoint.secret),
}
def get(self, request, webhook_id):
"""Get a specific webhook endpoint"""
import stripe
from djstripe.models import WebhookEndpoint
settings = PlatformSettings.get_instance()
if not settings.has_stripe_keys():
return Response(
{'error': 'Stripe keys not configured'},
status=status.HTTP_400_BAD_REQUEST
)
try:
stripe.api_key = settings.get_stripe_secret_key()
endpoint = stripe.WebhookEndpoint.retrieve(webhook_id)
local_wh = WebhookEndpoint.sync_from_stripe_data(endpoint)
return Response({'webhook': self._format_webhook(local_wh)})
except stripe.error.InvalidRequestError as e:
return Response(
{'error': 'Webhook endpoint not found'},
status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
def patch(self, request, webhook_id):
"""Update a webhook endpoint"""
import stripe
from djstripe.models import WebhookEndpoint
settings = PlatformSettings.get_instance()
if not settings.has_stripe_keys():
return Response(
{'error': 'Stripe keys not configured'},
status=status.HTTP_400_BAD_REQUEST
)
try:
stripe.api_key = settings.get_stripe_secret_key()
# Build update params
update_params = {}
if 'url' in request.data:
url = request.data['url']
if not url.startswith('https://'):
return Response(
{'error': 'Webhook URL must use HTTPS'},
status=status.HTTP_400_BAD_REQUEST
)
update_params['url'] = url
if 'enabled_events' in request.data:
update_params['enabled_events'] = request.data['enabled_events']
if 'disabled' in request.data:
update_params['disabled'] = request.data['disabled']
if 'description' in request.data:
update_params['description'] = request.data['description']
if not update_params:
return Response(
{'error': 'No valid fields to update'},
status=status.HTTP_400_BAD_REQUEST
)
# Update on Stripe
endpoint = stripe.WebhookEndpoint.modify(webhook_id, **update_params)
# Sync to local database
local_wh = WebhookEndpoint.sync_from_stripe_data(endpoint)
return Response({
'webhook': self._format_webhook(local_wh),
'message': 'Webhook updated successfully',
})
except stripe.error.InvalidRequestError as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
def delete(self, request, webhook_id):
"""Delete a webhook endpoint"""
import stripe
from djstripe.models import WebhookEndpoint
settings = PlatformSettings.get_instance()
if not settings.has_stripe_keys():
return Response(
{'error': 'Stripe keys not configured'},
status=status.HTTP_400_BAD_REQUEST
)
try:
stripe.api_key = settings.get_stripe_secret_key()
# Delete on Stripe
stripe.WebhookEndpoint.delete(webhook_id)
# Delete from local database
WebhookEndpoint.objects.filter(id=webhook_id).delete()
return Response({
'message': 'Webhook deleted successfully',
}, status=status.HTTP_200_OK)
except stripe.error.InvalidRequestError as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class StripeWebhookRotateSecretView(APIView):
"""
POST /api/platform/settings/stripe/webhooks/<id>/rotate-secret/
Rotate the webhook signing secret
"""
permission_classes = [IsAuthenticated, IsPlatformAdmin]
def post(self, request, webhook_id):
"""Rotate the webhook signing secret"""
import stripe
from djstripe.models import WebhookEndpoint
settings = PlatformSettings.get_instance()
if not settings.has_stripe_keys():
return Response(
{'error': 'Stripe keys not configured'},
status=status.HTTP_400_BAD_REQUEST
)
try:
stripe.api_key = settings.get_stripe_secret_key()
# Rotate the secret - this creates a new secret while keeping the old one valid briefly
# Note: Stripe API doesn't have a direct "rotate" - we need to delete and recreate
# or use the webhook endpoint's secret rotation if available
# Get current endpoint
current = stripe.WebhookEndpoint.retrieve(webhook_id)
# Delete and recreate with same settings
stripe.WebhookEndpoint.delete(webhook_id)
new_endpoint = stripe.WebhookEndpoint.create(
url=current.url,
enabled_events=current.enabled_events,
description=current.get('description', ''),
metadata=current.get('metadata', {}),
)
new_secret = new_endpoint.secret
# Sync to local database
WebhookEndpoint.objects.filter(id=webhook_id).delete()
local_wh = WebhookEndpoint.sync_from_stripe_data(new_endpoint)
local_wh.secret = new_secret
local_wh.save()
# Update platform settings if this was the primary webhook
if request.data.get('update_platform_secret', False):
settings.stripe_webhook_secret = new_secret
settings.save()
return Response({
'webhook_id': new_endpoint.id,
'secret': new_secret,
'message': 'Webhook secret rotated. Save the new secret - it will not be shown again.',
'note': 'The webhook ID has changed due to recreation.',
})
except stripe.error.InvalidRequestError as e:
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class SubscriptionPlanViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing subscription plans.
Platform admins only.
"""
queryset = SubscriptionPlan.objects.all().order_by('price_monthly', 'name')
serializer_class = SubscriptionPlanSerializer
permission_classes = [IsAuthenticated, IsPlatformAdmin]
def get_serializer_class(self):
if self.action == 'create':
return SubscriptionPlanCreateSerializer
return SubscriptionPlanSerializer
@action(detail=False, methods=['post'])
def sync_with_stripe(self, request):
"""
Sync subscription plans with Stripe products.
Creates Stripe products/prices for plans that don't have them.
"""
import stripe
from django.conf import settings
stripe.api_key = settings.STRIPE_SECRET_KEY
if not stripe.api_key:
return Response(
{'error': 'Stripe API key not configured'},
status=status.HTTP_400_BAD_REQUEST
)
synced = []
errors = []
for plan in SubscriptionPlan.objects.filter(is_active=True):
# Skip if already has Stripe IDs
if plan.stripe_product_id and plan.stripe_price_id:
synced.append({
'id': plan.id,
'name': plan.name,
'status': 'already_synced'
})
continue
try:
# Create or retrieve product
if not plan.stripe_product_id:
product = stripe.Product.create(
name=plan.name,
description=plan.description or f"{plan.name} subscription plan",
metadata={
'plan_id': str(plan.id),
'plan_type': plan.plan_type,
'business_tier': plan.business_tier
}
)
plan.stripe_product_id = product.id
else:
product = stripe.Product.retrieve(plan.stripe_product_id)
# Create price if we have monthly pricing
if not plan.stripe_price_id and plan.price_monthly:
price = stripe.Price.create(
product=product.id,
unit_amount=int(plan.price_monthly * 100),
currency='usd',
recurring={'interval': 'month'},
metadata={'plan_id': str(plan.id)}
)
plan.stripe_price_id = price.id
plan.save()
synced.append({
'id': plan.id,
'name': plan.name,
'status': 'synced',
'stripe_product_id': plan.stripe_product_id,
'stripe_price_id': plan.stripe_price_id
})
except stripe.error.StripeError as e:
errors.append({
'id': plan.id,
'name': plan.name,
'error': str(e)
})
return Response({
'synced': synced,
'errors': errors,
'message': f'Synced {len(synced)} plans, {len(errors)} errors'
})
class TenantViewSet(viewsets.ModelViewSet):
"""
ViewSet for viewing, creating, and updating tenants (businesses).
@@ -191,10 +887,9 @@ class TenantInvitationViewSet(viewsets.ModelViewSet):
# The create method on the model will handle cancelling old invitations
# and generating token/expires_at.
instance = serializer.save(invited_by=self.request.user)
# TODO: Send invitation email here (e.g., using Celery task)
# Placeholder for email sending:
# from .tasks import send_invitation_email
# send_invitation_email.delay(instance.id)
# Send invitation email via Celery task
from .tasks import send_tenant_invitation_email
send_tenant_invitation_email.delay(instance.id)
@action(detail=True, methods=['post'])
def resend(self, request, pk=None):
@@ -210,10 +905,9 @@ class TenantInvitationViewSet(viewsets.ModelViewSet):
invitation.token = secrets.token_urlsafe(32) # Generate new token
invitation.save()
# TODO: Send invitation email here (e.g., using Celery task)
# Placeholder for email sending:
# from .tasks import send_invitation_email
# send_invitation_email.delay(invitation.id)
# Send invitation email via Celery task
from .tasks import send_tenant_invitation_email
send_tenant_invitation_email.delay(invitation.id)
return Response({"detail": "Invitation email resent successfully."}, status=status.HTTP_200_OK)
@action(detail=True, methods=['post'])

View File

@@ -195,5 +195,7 @@ dependencies = [
"sentry-sdk==2.46.0",
"whitenoise==6.11.0",
"stripe>=7.0.0",
"dj-stripe>=2.9.0",
"django-csp==3.8.0",
"twilio>=9.0.0",
]

View File

@@ -273,6 +273,7 @@ class AppointmentReminderPlugin(BasePlugin):
def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
from .models import Event
from platform_admin.tasks import send_appointment_reminder_email
hours_before = self.config.get('hours_before', 24)
method = self.config.get('method', 'email')
@@ -286,21 +287,39 @@ class AppointmentReminderPlugin(BasePlugin):
upcoming_events = Event.objects.filter(
start_time__gte=reminder_start,
start_time__lte=reminder_end,
status='SCHEDULED',
)
status=Event.Status.SCHEDULED,
).prefetch_related('participants__customer')
reminders_sent = 0
reminders_failed = 0
for event in upcoming_events:
# TODO: Get customer emails from participants
# TODO: Send actual reminders based on method
logger.info(f"Would send reminder for event: {event.title}")
reminders_sent += 1
# 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:
if method in ['email', 'both']:
# Queue email reminder via Celery
send_appointment_reminder_email.delay(
event_id=event.id,
customer_email=customer_email,
hours_before=hours_before
)
reminders_sent += 1
logger.info(f"Queued email reminder for {customer_email} - event: {event.title}")
if method in ['sms', 'both']:
# SMS would go here via Twilio
# For now, just log the intent
logger.info(f"Would send SMS reminder to customer for event: {event.title}")
return {
'success': True,
'message': f"Sent {reminders_sent} reminder(s)",
'message': f"Queued {reminders_sent} reminder(s)",
'data': {
'reminders_sent': reminders_sent,
'reminders_queued': reminders_sent,
'reminders_failed': reminders_failed,
'hours_before': hours_before,
'method': method,
},

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.8 on 2025-11-29 18:33
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0022_add_apply_to_existing_flag'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EmailTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('subject', models.CharField(help_text='Email subject line - supports template variables like {{CUSTOMER_NAME}}', max_length=500)),
('html_content', models.TextField(blank=True, help_text='HTML email body')),
('text_content', models.TextField(blank=True, help_text='Plain text email body (fallback for non-HTML clients)')),
('scope', models.CharField(choices=[('BUSINESS', 'Business'), ('PLATFORM', 'Platform')], default='BUSINESS', max_length=20)),
('is_default', models.BooleanField(default=False, help_text='Default template for certain system triggers')),
('category', models.CharField(choices=[('APPOINTMENT', 'Appointment'), ('REMINDER', 'Reminder'), ('CONFIRMATION', 'Confirmation'), ('MARKETING', 'Marketing'), ('NOTIFICATION', 'Notification'), ('REPORT', 'Report'), ('OTHER', 'Other')], default='OTHER', max_length=50)),
('preview_context', models.JSONField(blank=True, default=dict, help_text='Sample data for rendering preview')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_email_templates', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
'indexes': [models.Index(fields=['scope', 'category'], name='schedule_em_scope_e4cf94_idx')],
},
),
]

View File

@@ -1176,7 +1176,9 @@ class PluginInstallation(models.Model):
def __str__(self):
template_name = self.template.name if self.template else "Deleted Template"
return f"{template_name} -> {self.scheduled_task.name}"
if self.scheduled_task:
return f"{template_name} -> {self.scheduled_task.name}"
return f"{template_name} (installed)"
def has_update_available(self):
"""Check if template has been updated since installation"""
@@ -1196,3 +1198,142 @@ class PluginInstallation(models.Model):
# Update version hash
self.template_version_hash = self.template.plugin_code_hash
self.save()
class EmailTemplate(models.Model):
"""
Reusable email template for plugins and automations.
Supports both text and HTML content with template variable substitution.
Business templates are tenant-specific, Platform templates are shared/system-wide.
"""
class Scope(models.TextChoices):
BUSINESS = 'BUSINESS', 'Business' # Tenant-specific
PLATFORM = 'PLATFORM', 'Platform' # Platform-wide (shared)
class Category(models.TextChoices):
APPOINTMENT = 'APPOINTMENT', 'Appointment'
REMINDER = 'REMINDER', 'Reminder'
CONFIRMATION = 'CONFIRMATION', 'Confirmation'
MARKETING = 'MARKETING', 'Marketing'
NOTIFICATION = 'NOTIFICATION', 'Notification'
REPORT = 'REPORT', 'Report'
OTHER = 'OTHER', 'Other'
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
# Email structure
subject = models.CharField(
max_length=500,
help_text="Email subject line - supports template variables like {{CUSTOMER_NAME}}"
)
html_content = models.TextField(
blank=True,
help_text="HTML email body"
)
text_content = models.TextField(
blank=True,
help_text="Plain text email body (fallback for non-HTML clients)"
)
# Scope
scope = models.CharField(
max_length=20,
choices=Scope.choices,
default=Scope.BUSINESS,
)
# Only for PLATFORM scope templates
is_default = models.BooleanField(
default=False,
help_text="Default template for certain system triggers"
)
# Category for organization
category = models.CharField(
max_length=50,
choices=Category.choices,
default=Category.OTHER,
)
# Preview data for visual preview
preview_context = models.JSONField(
default=dict,
blank=True,
help_text="Sample data for rendering preview"
)
# Metadata
created_by = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_email_templates'
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name']
indexes = [
models.Index(fields=['scope', 'category']),
]
def __str__(self):
return f"{self.name} ({self.get_scope_display()})"
def render(self, context: dict, force_footer: bool = False):
"""
Render the template with given context.
Args:
context: Dictionary of template variables
force_footer: If True, append "Powered by Smooth Schedule" footer
Returns:
Tuple of (subject, html_content, text_content)
"""
from .template_parser import TemplateVariableParser
subject = TemplateVariableParser.replace_insertion_codes(
self.subject, context
)
html = TemplateVariableParser.replace_insertion_codes(
self.html_content, context
) if self.html_content else ''
text = TemplateVariableParser.replace_insertion_codes(
self.text_content, context
) if self.text_content else ''
# Append footer for free tier if applicable
if force_footer:
html = self._append_html_footer(html)
text = self._append_text_footer(text)
return subject, html, text
def _append_html_footer(self, html: str) -> str:
"""Append Powered by Smooth Schedule footer to HTML"""
import re
footer = '''
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #9ca3af; font-size: 12px;">
<p>
Powered by
<a href="https://smoothschedule.com" style="color: #6366f1; text-decoration: none; font-weight: 500;">
SmoothSchedule
</a>
</p>
</div>
'''
# Insert before </body> if present, otherwise append
if '</body>' in html.lower():
return re.sub(r'(</body>)', footer + r'\1', html, flags=re.IGNORECASE)
return html + footer
def _append_text_footer(self, text: str) -> str:
"""Append Powered by Smooth Schedule footer to plain text"""
footer = "\n\n---\nPowered by SmoothSchedule - https://smoothschedule.com"
return text + footer

View File

@@ -4,7 +4,7 @@ DRF Serializers for Schedule App with Availability Validation
from rest_framework import serializers
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate
from .services import AvailabilityService
from smoothschedule.users.models import User
@@ -885,3 +885,64 @@ class GlobalEventPluginSerializer(serializers.ModelSerializer):
validated_data['created_by'] = request.user
return super().create(validated_data)
class EmailTemplateSerializer(serializers.ModelSerializer):
"""Full serializer for EmailTemplate CRUD operations"""
created_by_name = serializers.SerializerMethodField()
class Meta:
model = EmailTemplate
fields = [
'id', 'name', 'description', 'subject',
'html_content', 'text_content', 'scope',
'is_default', 'category', 'preview_context',
'created_by', 'created_by_name',
'created_at', 'updated_at',
]
read_only_fields = ['created_at', 'updated_at', 'created_by', 'created_by_name']
def get_created_by_name(self, obj):
"""Get the name of the user who created the template"""
if obj.created_by:
return obj.created_by.full_name or obj.created_by.username
return None
def validate(self, attrs):
"""Validate template content"""
html = attrs.get('html_content', '')
text = attrs.get('text_content', '')
# At least one content type is required
if not html and not text:
raise serializers.ValidationError(
"At least HTML or text content is required"
)
return attrs
def create(self, validated_data):
"""Set created_by from request context"""
request = self.context.get('request')
if request and hasattr(request, 'user') and request.user.is_authenticated:
validated_data['created_by'] = request.user
return super().create(validated_data)
class EmailTemplateListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for email template dropdowns and listings"""
class Meta:
model = EmailTemplate
fields = ['id', 'name', 'description', 'category', 'scope', 'updated_at']
read_only_fields = fields
class EmailTemplatePreviewSerializer(serializers.Serializer):
"""Serializer for email template preview endpoint"""
subject = serializers.CharField()
html_content = serializers.CharField(allow_blank=True, required=False, default='')
text_content = serializers.CharField(allow_blank=True, required=False, default='')
context = serializers.DictField(required=False, default=dict)

View File

@@ -4,9 +4,11 @@ Signals for the schedule app.
Handles:
1. Auto-attaching plugins from GlobalEventPlugin rules when events are created
2. Rescheduling Celery tasks when events are modified (time/duration changes)
3. Scheduling/cancelling Celery tasks when EventPlugins are created/deleted/modified
4. Cancelling tasks when Events are deleted or cancelled
"""
import logging
from django.db.models.signals import post_save, pre_save
from django.db.models.signals import post_save, pre_save, post_delete, pre_delete
from django.dispatch import receiver
logger = logging.getLogger(__name__)
@@ -159,6 +161,8 @@ def schedule_event_plugin_task(event_plugin, execution_time):
# Task name is unique per event-plugin combination
task_name = f"event_plugin_{event_plugin.id}"
import json
# Create or update the periodic task
task, created = PeriodicTask.objects.update_or_create(
name=task_name,
@@ -167,7 +171,7 @@ def schedule_event_plugin_task(event_plugin, execution_time):
'clocked': clocked_schedule,
'one_off': True, # Run only once
'enabled': event_plugin.is_active,
'kwargs': str({
'kwargs': json.dumps({
'event_plugin_id': event_plugin.id,
'event_id': event_plugin.event_id,
}),
@@ -209,3 +213,103 @@ def apply_global_plugin_to_existing_events(sender, instance, created, **kwargs):
logger.info(
f"Applied global plugin rule '{instance}' to {count} existing events"
)
# ============================================================================
# EventPlugin Scheduling Signals
# ============================================================================
@receiver(post_save, sender='schedule.EventPlugin')
def schedule_event_plugin_on_create(sender, instance, created, **kwargs):
"""
When an EventPlugin is created or updated, schedule its Celery task
if it has a time-based trigger.
"""
# Only schedule time-based triggers
time_based_triggers = ['before_start', 'at_start', 'after_start', 'after_end']
if instance.trigger not in time_based_triggers:
return
if not instance.is_active:
# If deactivated, cancel any existing task
from .tasks import cancel_event_plugin_task
cancel_event_plugin_task(instance.id)
return
execution_time = instance.get_execution_time()
if execution_time:
schedule_event_plugin_task(instance, execution_time)
@receiver(pre_save, sender='schedule.EventPlugin')
def track_event_plugin_active_change(sender, instance, **kwargs):
"""
Track if is_active changed so we can cancel tasks when deactivated.
"""
if instance.pk:
try:
from .models import EventPlugin
old_instance = EventPlugin.objects.get(pk=instance.pk)
instance._was_active = old_instance.is_active
except sender.DoesNotExist:
instance._was_active = None
else:
instance._was_active = None
@receiver(post_delete, sender='schedule.EventPlugin')
def cancel_event_plugin_on_delete(sender, instance, **kwargs):
"""
When an EventPlugin is deleted, cancel its scheduled Celery task.
"""
from .tasks import cancel_event_plugin_task
cancel_event_plugin_task(instance.id)
# ============================================================================
# Event Deletion/Cancellation Signals
# ============================================================================
@receiver(pre_delete, sender='schedule.Event')
def cancel_event_tasks_on_delete(sender, instance, **kwargs):
"""
When an Event is deleted, cancel all its scheduled plugin tasks.
"""
from .tasks import cancel_event_tasks
cancel_event_tasks(instance.id)
@receiver(pre_save, sender='schedule.Event')
def track_event_status_change(sender, instance, **kwargs):
"""
Track status changes to detect cancellation.
"""
if instance.pk:
try:
from .models import Event
old_instance = Event.objects.get(pk=instance.pk)
instance._old_status = old_instance.status
except sender.DoesNotExist:
instance._old_status = None
else:
instance._old_status = None
@receiver(post_save, sender='schedule.Event')
def cancel_event_tasks_on_cancel(sender, instance, created, **kwargs):
"""
When an Event is cancelled, cancel all its scheduled plugin tasks.
"""
if created:
return
from .models import Event
old_status = getattr(instance, '_old_status', None)
# If status changed to cancelled, cancel all tasks
if old_status != Event.Status.CANCELED and instance.status == Event.Status.CANCELED:
from .tasks import cancel_event_tasks
logger.info(f"Event '{instance}' was cancelled, cancelling all plugin tasks")
cancel_event_tasks(instance.id)

View File

@@ -214,3 +214,177 @@ def check_and_schedule_tasks():
logger.info(f"Scheduled task {task.name} to run at {task.next_run_at}")
return {'scheduled_count': scheduled_count}
@shared_task(bind=True, max_retries=3)
def execute_event_plugin(self, event_plugin_id: int, event_id: int = None):
"""
Execute a plugin for a specific event at a scheduled time.
This task is scheduled by django-celery-beat when EventPlugins are created
with time-based triggers (before_start, at_start, after_start, after_end).
Args:
event_plugin_id: ID of the EventPlugin to execute
event_id: Optional event ID for validation
Returns:
dict: Execution result
"""
from .models import EventPlugin, Event
from .plugins import PluginExecutionError
start_time = time.time()
try:
event_plugin = EventPlugin.objects.select_related(
'event', 'plugin_installation', 'plugin_installation__template'
).get(id=event_plugin_id)
except EventPlugin.DoesNotExist:
logger.error(f"EventPlugin {event_plugin_id} not found")
return {'success': False, 'error': 'EventPlugin not found'}
# Validate event if provided
if event_id and event_plugin.event_id != event_id:
logger.error(f"Event mismatch: expected {event_id}, got {event_plugin.event_id}")
return {'success': False, 'error': 'Event mismatch'}
event = event_plugin.event
# Check if plugin is still active
if not event_plugin.is_active:
logger.info(f"Skipping EventPlugin {event_plugin_id} - not active")
return {'success': False, 'skipped': True, 'reason': 'Plugin not active'}
# Check if event is in a valid state (not cancelled)
if event.status == Event.Status.CANCELLED:
logger.info(f"Skipping EventPlugin {event_plugin_id} - event is cancelled")
return {'success': False, 'skipped': True, 'reason': 'Event cancelled'}
plugin_name = event_plugin.plugin_installation.template.name if event_plugin.plugin_installation.template else 'Unknown'
try:
# Get the plugin instance from the installation
plugin_installation = event_plugin.plugin_installation
plugin = plugin_installation.get_plugin_instance()
if not plugin:
raise PluginExecutionError(f"Plugin '{plugin_name}' not found or not loaded")
# Get business/tenant context
from django.db import connection
business = None
if hasattr(connection, 'tenant'):
business = connection.tenant
# Build execution context with event-specific data
context = {
'business': business,
'event': event,
'event_plugin': event_plugin,
'trigger': event_plugin.trigger,
'execution_time': timezone.now(),
'plugin_installation': plugin_installation,
# Include participants for the plugin to use
'participants': list(event.participants.select_related('resource', 'customer').all()),
}
# Check if plugin can execute
can_execute, reason = plugin.can_execute(context)
if not can_execute:
logger.info(f"Skipping EventPlugin {event_plugin_id}: {reason}")
return {'success': False, 'skipped': True, 'reason': reason}
# Execute plugin
logger.info(f"Executing EventPlugin {event_plugin_id} ({plugin_name}) for event '{event.title}'")
result = plugin.execute(context)
execution_time_ms = int((time.time() - start_time) * 1000)
# Call success callback
try:
plugin.on_success(result)
except Exception as callback_error:
logger.error(f"Plugin success callback failed: {callback_error}", exc_info=True)
logger.info(f"EventPlugin {event_plugin_id} completed successfully in {execution_time_ms}ms")
return {
'success': True,
'result': result,
'execution_time_ms': execution_time_ms,
'event_id': event.id,
'plugin_name': plugin_name,
}
except Exception as error:
execution_time_ms = int((time.time() - start_time) * 1000)
# Call failure callback if plugin exists
try:
plugin_installation = event_plugin.plugin_installation
plugin = plugin_installation.get_plugin_instance()
if plugin:
plugin.on_failure(error)
except Exception as callback_error:
logger.error(f"Plugin failure callback failed: {callback_error}", exc_info=True)
logger.error(f"EventPlugin {event_plugin_id} failed: {error}", exc_info=True)
# Retry with exponential backoff
raise self.retry(exc=error, countdown=60 * (2 ** self.request.retries))
def cancel_event_plugin_task(event_plugin_id: int):
"""
Cancel a scheduled Celery task for an EventPlugin.
This is called when:
- An EventPlugin is deleted
- An EventPlugin is deactivated
- An Event is cancelled/deleted
Args:
event_plugin_id: ID of the EventPlugin whose task should be cancelled
"""
try:
from django_celery_beat.models import PeriodicTask
task_name = f"event_plugin_{event_plugin_id}"
deleted_count, _ = PeriodicTask.objects.filter(name=task_name).delete()
if deleted_count > 0:
logger.info(f"Cancelled Celery task '{task_name}'")
else:
logger.debug(f"No Celery task found for '{task_name}'")
return deleted_count > 0
except ImportError:
logger.warning("django-celery-beat not installed, cannot cancel task")
return False
except Exception as e:
logger.error(f"Failed to cancel event plugin task: {e}")
return False
def cancel_event_tasks(event_id: int):
"""
Cancel all scheduled Celery tasks for an event.
Called when an event is deleted or cancelled.
Args:
event_id: ID of the Event whose plugin tasks should be cancelled
"""
from .models import EventPlugin
event_plugins = EventPlugin.objects.filter(event_id=event_id)
cancelled_count = 0
for ep in event_plugins:
if cancel_event_plugin_task(ep.id):
cancelled_count += 1
logger.info(f"Cancelled {cancelled_count} Celery tasks for event {event_id}")
return cancelled_count

View File

@@ -4,19 +4,51 @@ Template Variable Parser for Plugin System
Parses template variables with formats:
- {{PROMPT:variable_name|description}} - User input
- {{PROMPT:variable_name|description|default}} - User input with default value
- {{PROMPT:variable_name|description|default|textarea}} - Multi-line text input
- {{PROMPT:variable_name|description|default|type}} - With explicit type
- {{PROMPT:variable_name|description||type}} - No default, explicit type
Supported types:
- text - Single-line text input (default)
- textarea - Multi-line text input
- email - Email address with validation
- number - Numeric input
- url - URL/webhook endpoint
- email_template - Dropdown to select from email templates
Other template formats:
- {{CONTEXT:field_name}} - Auto-filled business context
- {{DATE:expression}} - Date/time helpers
Insertion codes for use within PROMPT templates (e.g., email bodies):
Business context:
- {{BUSINESS_NAME}} - Business name
- {{BUSINESS_EMAIL}} - Business contact email
- {{BUSINESS_PHONE}} - Business phone number
- {{CUSTOMER_NAME}} - Customer's name (in appointment contexts)
- {{CUSTOMER_EMAIL}} - Customer's email (in appointment contexts)
- {{APPOINTMENT_TIME}} - Appointment date/time (in appointment contexts)
Customer context:
- {{CUSTOMER_NAME}} - Customer's name
- {{CUSTOMER_EMAIL}} - Customer's email
Appointment context:
- {{APPOINTMENT_TIME}} - Appointment date/time
- {{APPOINTMENT_DATE}} - Appointment date only
- {{APPOINTMENT_SERVICE}} - Service name
Ticket context:
- {{TICKET_ID}} - Ticket ID number
- {{TICKET_SUBJECT}} - Ticket subject line
- {{TICKET_MESSAGE}} - Original ticket message
- {{TICKET_STATUS}} - Current ticket status
- {{TICKET_PRIORITY}} - Ticket priority level
- {{TICKET_CUSTOMER_NAME}} - Customer who created the ticket
- {{TICKET_URL}} - URL to view the ticket
- {{ASSIGNEE_NAME}} - Name of assigned staff member
- {{RECIPIENT_NAME}} - Name of email recipient
- {{REPLY_MESSAGE}} - Latest reply message
- {{RESOLUTION_MESSAGE}} - Resolution summary
Date/time helpers:
- {{TODAY}} - Today's date
- {{NOW}} - Current date and time
"""
@@ -40,8 +72,8 @@ class TemplateVariableParser:
# Pattern for date helpers: {{DATE:expression}}
DATE_PATTERN = r'\{\{DATE:([^}]+)\}\}'
# Pattern for insertion codes: {{BUSINESS_NAME}}, {{CUSTOMER_NAME}}, etc.
INSERTION_PATTERN = r'\{\{(BUSINESS_NAME|BUSINESS_EMAIL|BUSINESS_PHONE|CUSTOMER_NAME|CUSTOMER_EMAIL|APPOINTMENT_TIME|APPOINTMENT_DATE|APPOINTMENT_SERVICE|TODAY|NOW)\}\}'
# Pattern for insertion codes: {{BUSINESS_NAME}}, {{CUSTOMER_NAME}}, {{TICKET_ID}}, etc.
INSERTION_PATTERN = r'\{\{(BUSINESS_NAME|BUSINESS_EMAIL|BUSINESS_PHONE|CUSTOMER_NAME|CUSTOMER_EMAIL|APPOINTMENT_TIME|APPOINTMENT_DATE|APPOINTMENT_SERVICE|TICKET_ID|TICKET_SUBJECT|TICKET_MESSAGE|TICKET_STATUS|TICKET_PRIORITY|TICKET_CUSTOMER_NAME|TICKET_URL|ASSIGNEE_NAME|RECIPIENT_NAME|REPLY_MESSAGE|RESOLUTION_MESSAGE|TODAY|NOW)\}\}'
@classmethod
def extract_variables(cls, template: str) -> List[Dict[str, str]]:
@@ -114,7 +146,8 @@ class TemplateVariableParser:
label = cls._variable_to_label(var_name)
# Use explicit type if provided, otherwise infer
if explicit_type and explicit_type.strip().lower() in ['text', 'textarea', 'email', 'number', 'url']:
# Supported types: text, textarea, email, number, url, email_template
if explicit_type and explicit_type.strip().lower() in ['text', 'textarea', 'email', 'number', 'url', 'email_template']:
var_type = explicit_type.strip().lower()
else:
var_type = cls._infer_type(var_name, description)
@@ -432,14 +465,30 @@ class TemplateVariableParser:
"""
# Map insertion codes to runtime variable names
insertion_map = {
# Business context
'BUSINESS_NAME': '{business_name}',
'BUSINESS_EMAIL': '{business_email}',
'BUSINESS_PHONE': '{business_phone}',
# Customer context
'CUSTOMER_NAME': '{customer_name}',
'CUSTOMER_EMAIL': '{customer_email}',
# Appointment context
'APPOINTMENT_TIME': '{appointment_time}',
'APPOINTMENT_DATE': '{appointment_date}',
'APPOINTMENT_SERVICE': '{appointment_service}',
# Ticket context
'TICKET_ID': '{ticket_id}',
'TICKET_SUBJECT': '{ticket_subject}',
'TICKET_MESSAGE': '{ticket_message}',
'TICKET_STATUS': '{ticket_status}',
'TICKET_PRIORITY': '{ticket_priority}',
'TICKET_CUSTOMER_NAME': '{ticket_customer_name}',
'TICKET_URL': '{ticket_url}',
'ASSIGNEE_NAME': '{assignee_name}',
'RECIPIENT_NAME': '{recipient_name}',
'REPLY_MESSAGE': '{reply_message}',
'RESOLUTION_MESSAGE': '{resolution_message}',
# Date/time helpers
'TODAY': '{today}',
'NOW': '{now}',
}

View File

@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Appointment Reminder</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.business-name {
font-size: 24px;
font-weight: bold;
color: #6366f1;
}
.reminder-badge {
display: inline-block;
background-color: #fef3c7;
color: #92400e;
padding: 6px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
margin-bottom: 20px;
}
h1 {
color: #1f2937;
font-size: 24px;
margin-bottom: 20px;
}
p {
color: #4b5563;
margin-bottom: 15px;
}
.appointment-card {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 12px;
padding: 25px;
margin: 25px 0;
color: white;
}
.appointment-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 15px;
}
.appointment-details {
display: flex;
flex-direction: column;
gap: 10px;
}
.appointment-row {
display: flex;
align-items: center;
gap: 10px;
}
.appointment-icon {
width: 20px;
height: 20px;
opacity: 0.9;
}
.appointment-label {
opacity: 0.9;
font-size: 14px;
}
.appointment-value {
font-weight: 500;
}
.calendar-icon::before { content: "📅"; }
.clock-icon::before { content: "🕐"; }
.duration-icon::before { content: "⏱️"; }
.staff-icon::before { content: "👤"; }
.info-box {
background-color: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
padding: 15px;
margin: 20px 0;
}
.info-box h3 {
color: #0369a1;
font-size: 14px;
margin: 0 0 8px 0;
}
.info-box p {
color: #0c4a6e;
margin: 0;
font-size: 14px;
}
.footer {
text-align: center;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
color: #9ca3af;
font-size: 14px;
}
.footer a {
color: #6366f1;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="business-name">{{ business_name }}</div>
</div>
<div style="text-align: center;">
<span class="reminder-badge">⏰ {{ hours_before }} Hour Reminder</span>
</div>
<h1>Your Upcoming Appointment</h1>
<p>Hi there,</p>
<p>
This is a friendly reminder about your upcoming appointment at <strong>{{ business_name }}</strong>.
We're looking forward to seeing you!
</p>
<div class="appointment-card">
<div class="appointment-title">{{ event.title }}</div>
<div class="appointment-details">
<div class="appointment-row">
<span class="calendar-icon"></span>
<span class="appointment-value">{{ event_date }}</span>
</div>
<div class="appointment-row">
<span class="clock-icon"></span>
<span class="appointment-value">{{ event_time }}</span>
</div>
<div class="appointment-row">
<span class="duration-icon"></span>
<span class="appointment-value">{{ duration_minutes }} minutes</span>
</div>
{% if staff_names %}
<div class="appointment-row">
<span class="staff-icon"></span>
<span class="appointment-value">{{ staff_names|join:", " }}</span>
</div>
{% endif %}
</div>
</div>
<div class="info-box">
<h3>Need to make changes?</h3>
<p>
If you need to reschedule or cancel your appointment, please contact us as soon as possible
so we can accommodate your needs.
</p>
</div>
<p>
We recommend arriving 5-10 minutes early to ensure a smooth check-in process.
</p>
<p>See you soon!</p>
<p><strong>The {{ business_name }} Team</strong></p>
<div class="footer">
<p>
This reminder was sent by {{ business_name }} using SmoothSchedule.<br>
<a href="#">Manage your notification preferences</a>
</p>
<p>&copy; {% now "Y" %} {{ business_name }}. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -8,7 +8,7 @@ from .views import (
CustomerViewSet, ServiceViewSet, StaffViewSet, ResourceTypeViewSet,
ScheduledTaskViewSet, TaskExecutionLogViewSet, PluginViewSet,
PluginTemplateViewSet, PluginInstallationViewSet, EventPluginViewSet,
GlobalEventPluginViewSet
GlobalEventPluginViewSet, EmailTemplateViewSet
)
# Create router and register viewsets
@@ -28,6 +28,7 @@ router.register(r'plugin-templates', PluginTemplateViewSet, basename='plugintemp
router.register(r'plugin-installations', PluginInstallationViewSet, basename='plugininstallation')
router.register(r'event-plugins', EventPluginViewSet, basename='eventplugin')
router.register(r'global-event-plugins', GlobalEventPluginViewSet, basename='globaleventplugin')
router.register(r'email-templates', EmailTemplateViewSet, basename='emailtemplate')
# URL patterns
urlpatterns = [

View File

@@ -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],
})

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
Generate simple placeholder icons for platform plugins using Pillow.
This script creates gradient icons with initials/symbols for each plugin.
No API key required - uses pure Python image generation.
Usage:
python generate_placeholder_icons.py
"""
from pathlib import Path
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("Error: Pillow package not installed")
print("Install with: pip install Pillow")
exit(1)
# Plugin definitions with colors and symbols
PLUGINS = [
{
'slug': 'daily-appointment-summary',
'name': 'Daily Appointment Summary',
'symbol': '📅',
'initials': 'DS',
'colors': ('#6366f1', '#8b5cf6'), # Indigo to violet
},
{
'slug': 'no-show-tracker',
'name': 'No-Show Customer Tracker',
'symbol': '',
'initials': 'NS',
'colors': ('#f97316', '#ef4444'), # Orange to red
},
{
'slug': 'birthday-greetings',
'name': 'Birthday Greeting Campaign',
'symbol': '🎂',
'initials': 'BG',
'colors': ('#ec4899', '#f472b6'), # Pink shades
},
{
'slug': 'monthly-revenue-report',
'name': 'Monthly Revenue Report',
'symbol': '📈',
'initials': 'MR',
'colors': ('#22c55e', '#10b981'), # Green shades
},
{
'slug': 'appointment-reminder-24hr',
'name': 'Appointment Reminder (24hr)',
'symbol': '🔔',
'initials': 'AR',
'colors': ('#0ea5e9', '#06b6d4'), # Sky to cyan
},
{
'slug': 'inactive-customer-reengagement',
'name': 'Inactive Customer Re-engagement',
'symbol': '🔄',
'initials': 'IR',
'colors': ('#f59e0b', '#fbbf24'), # Amber shades
},
]
def hex_to_rgb(hex_color: str) -> tuple:
"""Convert hex color to RGB tuple."""
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def create_gradient(size: int, color1: str, color2: str) -> Image.Image:
"""Create a diagonal gradient image."""
img = Image.new('RGB', (size, size))
c1 = hex_to_rgb(color1)
c2 = hex_to_rgb(color2)
for y in range(size):
for x in range(size):
# Diagonal gradient
t = (x + y) / (2 * size)
r = int(c1[0] * (1 - t) + c2[0] * t)
g = int(c1[1] * (1 - t) + c2[1] * t)
b = int(c1[2] * (1 - t) + c2[2] * t)
img.putpixel((x, y), (r, g, b))
return img
def add_rounded_corners(img: Image.Image, radius: int) -> Image.Image:
"""Add rounded corners to an image."""
# Create a mask with rounded corners
mask = Image.new('L', img.size, 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle([(0, 0), img.size], radius=radius, fill=255)
# Apply mask
result = Image.new('RGBA', img.size, (0, 0, 0, 0))
result.paste(img, mask=mask)
return result
def generate_icon(plugin: dict, output_dir: Path, size: int = 256) -> bool:
"""Generate a placeholder icon for a plugin."""
slug = plugin['slug']
initials = plugin['initials']
color1, color2 = plugin['colors']
output_path = output_dir / f"{slug}.png"
print(f"Generating icon for: {plugin['name']}")
print(f" Output: {output_path}")
try:
# Create gradient background
img = create_gradient(size, color1, color2)
draw = ImageDraw.Draw(img)
# Try to use a nice font, fall back to default
font_size = size // 3
try:
# Try common system fonts
for font_name in ['DejaVuSans-Bold.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'Arial Bold.ttf', 'Helvetica Bold.ttf']:
try:
font = ImageFont.truetype(font_name, font_size)
break
except OSError:
continue
else:
font = ImageFont.load_default()
except Exception:
font = ImageFont.load_default()
# Draw initials centered
bbox = draw.textbbox((0, 0), initials, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (size - text_width) // 2
y = (size - text_height) // 2 - bbox[1]
# Draw text with slight shadow for depth
shadow_offset = 2
draw.text((x + shadow_offset, y + shadow_offset), initials, fill=(0, 0, 0, 80), font=font)
draw.text((x, y), initials, fill='white', font=font)
# Add rounded corners
img = add_rounded_corners(img, radius=size // 8)
# Save
img.save(output_path, 'PNG')
print(f" ✓ Saved to {output_path}")
return True
except Exception as e:
print(f" ✗ Error: {e}")
return False
def main():
# Setup output directory
script_dir = Path(__file__).parent
project_root = script_dir.parent
output_dir = project_root / "static" / "plugin-logos"
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Output directory: {output_dir}")
print(f"Generating {len(PLUGINS)} placeholder icons...\n")
# Generate icons
success_count = 0
for plugin in PLUGINS:
if generate_icon(plugin, output_dir):
success_count += 1
print()
print("=" * 50)
print(f"Generated {success_count}/{len(PLUGINS)} icons successfully")
print(f"Icons saved to: {output_dir}")
if success_count > 0:
print("\nNext steps:")
print("1. Review the generated placeholder icons")
print("2. Update plugin logo_url paths in seed_platform_plugins.py")
print("3. Optionally replace with AI-generated icons later using generate_plugin_icons.py")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
Generate plugin icons using Google Gemini API.
Usage:
export GOOGLE_API_KEY="your-api-key"
python generate_plugin_icons.py
The icons will be saved to smoothschedule/static/plugin-logos/
"""
import os
import sys
from pathlib import Path
# Check for API key first
GOOGLE_API_KEY = os.environ.get('GOOGLE_API_KEY')
if not GOOGLE_API_KEY:
print("Error: GOOGLE_API_KEY environment variable not set")
print("Get your API key from: https://aistudio.google.com/app/apikey")
sys.exit(1)
try:
from google import genai
from google.genai import types
except ImportError:
print("Error: google-genai package not installed")
print("Install with: pip install google-genai")
sys.exit(1)
try:
from PIL import Image
except ImportError:
print("Error: Pillow package not installed")
print("Install with: pip install Pillow")
sys.exit(1)
# Plugin definitions with icon generation prompts
PLUGINS = [
{
'slug': 'daily-appointment-summary',
'name': 'Daily Appointment Summary Email',
'prompt': '''Create a minimalist, modern app icon for a "Daily Appointment Summary" plugin.
The icon should feature:
- A calendar or schedule symbol with check marks
- An email envelope element
- Clean, flat design style
- Purple/blue gradient color scheme
- Professional business aesthetic
- Square format with rounded corners
- No text, pure iconography
Size: 256x256 pixels, suitable for app store or dashboard display.'''
},
{
'slug': 'no-show-tracker',
'name': 'No-Show Customer Tracker',
'prompt': '''Create a minimalist, modern app icon for a "No-Show Tracker" plugin.
The icon should feature:
- A calendar with an X mark or empty chair symbol
- Alert/warning element
- Clean, flat design style
- Orange/red accent colors with neutral background
- Professional business aesthetic
- Square format with rounded corners
- No text, pure iconography
Size: 256x256 pixels, suitable for app store or dashboard display.'''
},
{
'slug': 'birthday-greetings',
'name': 'Birthday Greeting Campaign',
'prompt': '''Create a minimalist, modern app icon for a "Birthday Greetings" plugin.
The icon should feature:
- A birthday cake or gift box symbol
- A small heart or celebration element
- Clean, flat design style
- Pink/magenta warm color scheme
- Friendly, celebratory aesthetic
- Square format with rounded corners
- No text, pure iconography
Size: 256x256 pixels, suitable for app store or dashboard display.'''
},
{
'slug': 'monthly-revenue-report',
'name': 'Monthly Revenue Report',
'prompt': '''Create a minimalist, modern app icon for a "Monthly Revenue Report" plugin.
The icon should feature:
- A bar chart or line graph going upward
- A dollar sign or currency symbol
- Clean, flat design style
- Green money-themed color scheme
- Professional business/finance aesthetic
- Square format with rounded corners
- No text, pure iconography
Size: 256x256 pixels, suitable for app store or dashboard display.'''
},
{
'slug': 'appointment-reminder-24hr',
'name': 'Appointment Reminder (24hr)',
'prompt': '''Create a minimalist, modern app icon for an "Appointment Reminder" plugin.
The icon should feature:
- A bell or notification symbol
- A clock showing 24 hours or time element
- Clean, flat design style
- Blue/teal professional color scheme
- Urgent but friendly aesthetic
- Square format with rounded corners
- No text, pure iconography
Size: 256x256 pixels, suitable for app store or dashboard display.'''
},
{
'slug': 'inactive-customer-reengagement',
'name': 'Inactive Customer Re-engagement',
'prompt': '''Create a minimalist, modern app icon for a "Customer Re-engagement" plugin.
The icon should feature:
- A person silhouette with a returning arrow
- A magnet or heart symbol for attraction
- Clean, flat design style
- Warm orange/coral color scheme
- Welcoming, inviting aesthetic
- Square format with rounded corners
- No text, pure iconography
Size: 256x256 pixels, suitable for app store or dashboard display.'''
},
]
def generate_icon(client, plugin: dict, output_dir: Path) -> bool:
"""Generate an icon for a single plugin."""
slug = plugin['slug']
name = plugin['name']
prompt = plugin['prompt']
output_path = output_dir / f"{slug}.png"
print(f"\nGenerating icon for: {name}")
print(f" Output: {output_path}")
try:
response = client.models.generate_content(
model="gemini-2.5-flash-image",
contents=[prompt],
)
# Extract and save image from response
for part in response.parts:
if part.inline_data is not None:
image = part.as_image()
# Resize to 256x256 if needed
if image.size != (256, 256):
image = image.resize((256, 256), Image.Resampling.LANCZOS)
image.save(output_path, "PNG")
print(f" Success! Saved to {output_path}")
return True
elif part.text is not None:
print(f" Model response: {part.text[:200]}...")
print(f" Warning: No image generated for {name}")
return False
except Exception as e:
print(f" Error generating icon for {name}: {e}")
return False
def main():
# Setup output directory
script_dir = Path(__file__).parent
project_root = script_dir.parent
output_dir = project_root / "static" / "plugin-logos"
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Output directory: {output_dir}")
print(f"Generating {len(PLUGINS)} plugin icons using Gemini API...")
# Initialize Gemini client
client = genai.Client(api_key=GOOGLE_API_KEY)
# Generate icons
success_count = 0
for plugin in PLUGINS:
if generate_icon(client, plugin, output_dir):
success_count += 1
print(f"\n{'='*50}")
print(f"Generated {success_count}/{len(PLUGINS)} icons successfully")
print(f"Icons saved to: {output_dir}")
if success_count > 0:
print("\nNext steps:")
print("1. Review the generated icons")
print("2. Update the plugin logo_url paths in seed_platform_plugins.py")
print("3. Configure Django static files to serve from /static/plugin-logos/")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,940 @@
"""
Management command to seed default email templates.
These templates are created for new businesses and can be customized.
Platform templates are shared across all tenants.
Usage:
# Seed templates for all schemas
python manage.py seed_email_templates
# Seed templates for a specific schema
python manage.py seed_email_templates --schema=demo
# Force reset to defaults (overwrites existing)
python manage.py seed_email_templates --reset
"""
from django.core.management.base import BaseCommand
from django.db import connection
from django_tenants.utils import schema_context, get_tenant_model
class Command(BaseCommand):
help = 'Seed default email templates for tenants'
def add_arguments(self, parser):
parser.add_argument(
'--schema',
type=str,
help='Specific tenant schema to seed (default: all tenants)',
)
parser.add_argument(
'--reset',
action='store_true',
help='Reset templates to defaults (overwrites existing)',
)
def handle(self, *args, **options):
schema = options.get('schema')
reset = options.get('reset', False)
if schema:
# Seed specific schema
self.seed_schema(schema, reset)
else:
# Seed all tenant schemas
Tenant = get_tenant_model()
tenants = Tenant.objects.exclude(schema_name='public')
for tenant in tenants:
self.seed_schema(tenant.schema_name, reset)
self.stdout.write(self.style.SUCCESS('Email templates seeded successfully!'))
def seed_schema(self, schema_name, reset=False):
"""Seed templates for a specific schema"""
self.stdout.write(f'Seeding templates for schema: {schema_name}')
with schema_context(schema_name):
from schedule.models import EmailTemplate
templates = self.get_default_templates()
for template_data in templates:
name = template_data['name']
if reset:
# Delete existing and recreate
EmailTemplate.objects.filter(name=name).delete()
EmailTemplate.objects.create(**template_data)
self.stdout.write(f' Reset: {name}')
else:
# Only create if doesn't exist
_, created = EmailTemplate.objects.get_or_create(
name=name,
defaults=template_data
)
if created:
self.stdout.write(f' Created: {name}')
else:
self.stdout.write(f' Skipped (exists): {name}')
def get_default_templates(self):
"""Return list of default email templates"""
return [
# ========== CONFIRMATION TEMPLATES ==========
{
'name': 'Appointment Confirmation',
'description': 'Sent when a customer books an appointment',
'category': 'CONFIRMATION',
'scope': 'BUSINESS',
'subject': 'Your appointment at {{BUSINESS_NAME}} is confirmed!',
'html_content': self.get_appointment_confirmation_html(),
'text_content': self.get_appointment_confirmation_text(),
},
# ========== REMINDER TEMPLATES ==========
{
'name': 'Appointment Reminder - 24 Hours',
'description': 'Reminder sent 24 hours before appointment',
'category': 'REMINDER',
'scope': 'BUSINESS',
'subject': 'Reminder: Your appointment tomorrow at {{BUSINESS_NAME}}',
'html_content': self.get_appointment_reminder_html('24 hours'),
'text_content': self.get_appointment_reminder_text('24 hours'),
},
{
'name': 'Appointment Reminder - 1 Hour',
'description': 'Reminder sent 1 hour before appointment',
'category': 'REMINDER',
'scope': 'BUSINESS',
'subject': 'Reminder: Your appointment in 1 hour at {{BUSINESS_NAME}}',
'html_content': self.get_appointment_reminder_html('1 hour'),
'text_content': self.get_appointment_reminder_text('1 hour'),
},
# ========== NOTIFICATION TEMPLATES ==========
{
'name': 'Appointment Rescheduled',
'description': 'Sent when an appointment is rescheduled',
'category': 'NOTIFICATION',
'scope': 'BUSINESS',
'subject': 'Your appointment at {{BUSINESS_NAME}} has been rescheduled',
'html_content': self.get_appointment_rescheduled_html(),
'text_content': self.get_appointment_rescheduled_text(),
},
{
'name': 'Appointment Cancelled',
'description': 'Sent when an appointment is cancelled',
'category': 'NOTIFICATION',
'scope': 'BUSINESS',
'subject': 'Your appointment at {{BUSINESS_NAME}} has been cancelled',
'html_content': self.get_appointment_cancelled_html(),
'text_content': self.get_appointment_cancelled_text(),
},
{
'name': 'Thank You - Appointment Complete',
'description': 'Sent after an appointment is completed',
'category': 'NOTIFICATION',
'scope': 'BUSINESS',
'subject': 'Thank you for visiting {{BUSINESS_NAME}}!',
'html_content': self.get_thank_you_html(),
'text_content': self.get_thank_you_text(),
},
# ========== CUSTOMER ONBOARDING ==========
{
'name': 'Welcome New Customer',
'description': 'Welcome email for new customer accounts',
'category': 'NOTIFICATION',
'scope': 'BUSINESS',
'subject': 'Welcome to {{BUSINESS_NAME}}!',
'html_content': self.get_welcome_customer_html(),
'text_content': self.get_welcome_customer_text(),
},
# ========== TICKET NOTIFICATIONS ==========
{
'name': 'Ticket Assigned',
'description': 'Notification when a ticket is assigned to a staff member',
'category': 'NOTIFICATION',
'scope': 'BUSINESS',
'subject': '[Ticket #{{TICKET_ID}}] You have been assigned: {{TICKET_SUBJECT}}',
'html_content': self.get_ticket_assigned_html(),
'text_content': self.get_ticket_assigned_text(),
},
{
'name': 'Ticket Status Changed',
'description': 'Notification when ticket status changes',
'category': 'NOTIFICATION',
'scope': 'BUSINESS',
'subject': '[Ticket #{{TICKET_ID}}] Status updated: {{TICKET_STATUS}}',
'html_content': self.get_ticket_status_changed_html(),
'text_content': self.get_ticket_status_changed_text(),
},
{
'name': 'Ticket Reply - Staff Notification',
'description': 'Notification to staff when customer replies to ticket',
'category': 'NOTIFICATION',
'scope': 'BUSINESS',
'subject': '[Ticket #{{TICKET_ID}}] New reply from customer: {{TICKET_SUBJECT}}',
'html_content': self.get_ticket_reply_staff_html(),
'text_content': self.get_ticket_reply_staff_text(),
},
{
'name': 'Ticket Reply - Customer Notification',
'description': 'Notification to customer when staff replies to ticket',
'category': 'NOTIFICATION',
'scope': 'BUSINESS',
'subject': '[Ticket #{{TICKET_ID}}] {{BUSINESS_NAME}} has responded to your request',
'html_content': self.get_ticket_reply_customer_html(),
'text_content': self.get_ticket_reply_customer_text(),
},
{
'name': 'Ticket Resolved',
'description': 'Notification when a ticket is resolved/closed',
'category': 'NOTIFICATION',
'scope': 'BUSINESS',
'subject': '[Ticket #{{TICKET_ID}}] Your request has been resolved',
'html_content': self.get_ticket_resolved_html(),
'text_content': self.get_ticket_resolved_text(),
},
]
# ========== HTML TEMPLATES ==========
def get_email_wrapper_start(self, title=''):
return f'''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}}
.container {{
background-color: #ffffff;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}}
.header {{
text-align: center;
margin-bottom: 30px;
}}
.business-name {{
font-size: 24px;
font-weight: bold;
color: #6366f1;
}}
h1 {{
color: #1f2937;
font-size: 24px;
margin-bottom: 20px;
}}
p {{
color: #4b5563;
margin-bottom: 15px;
}}
.highlight-box {{
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 12px;
padding: 25px;
margin: 25px 0;
color: white;
}}
.info-row {{
display: flex;
margin-bottom: 10px;
}}
.info-label {{
font-weight: 600;
margin-right: 10px;
}}
.cta-button {{
display: inline-block;
background-color: #6366f1;
color: #ffffff !important;
text-decoration: none;
padding: 14px 32px;
border-radius: 8px;
font-weight: 600;
font-size: 16px;
margin: 20px 0;
}}
.footer {{
text-align: center;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
color: #9ca3af;
font-size: 14px;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="business-name">{{{{BUSINESS_NAME}}}}</div>
</div>
'''
def get_email_wrapper_end(self):
return '''
<div class="footer">
<p>
This email was sent by {{BUSINESS_NAME}}.<br>
If you have questions, please contact us at {{BUSINESS_EMAIL}}.
</p>
</div>
</div>
</body>
</html>
'''
def get_appointment_confirmation_html(self):
return self.get_email_wrapper_start('Appointment Confirmation') + '''
<h1>Your Appointment is Confirmed!</h1>
<p>Hi {{CUSTOMER_NAME}},</p>
<p>
Great news! Your appointment at <strong>{{BUSINESS_NAME}}</strong> has been confirmed.
We're looking forward to seeing you!
</p>
<div class="highlight-box">
<div class="info-row">
<span class="info-label">📅 Date:</span>
<span>{{APPOINTMENT_DATE}}</span>
</div>
<div class="info-row">
<span class="info-label">🕐 Time:</span>
<span>{{APPOINTMENT_TIME}}</span>
</div>
<div class="info-row">
<span class="info-label">💼 Service:</span>
<span>{{APPOINTMENT_SERVICE}}</span>
</div>
</div>
<p>
<strong>Need to make changes?</strong><br>
If you need to reschedule or cancel, please contact us as soon as possible.
</p>
<p>See you soon!</p>
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
''' + self.get_email_wrapper_end()
def get_appointment_confirmation_text(self):
return '''Your Appointment is Confirmed!
Hi {{CUSTOMER_NAME}},
Great news! Your appointment at {{BUSINESS_NAME}} has been confirmed.
APPOINTMENT DETAILS
-------------------
Date: {{APPOINTMENT_DATE}}
Time: {{APPOINTMENT_TIME}}
Service: {{APPOINTMENT_SERVICE}}
Need to make changes?
If you need to reschedule or cancel, please contact us as soon as possible.
See you soon!
The {{BUSINESS_NAME}} Team
---
{{BUSINESS_NAME}}
{{BUSINESS_EMAIL}}
{{BUSINESS_PHONE}}
'''
def get_appointment_reminder_html(self, time_before):
return self.get_email_wrapper_start('Appointment Reminder') + f'''
<h1>Reminder: Your Appointment is Coming Up!</h1>
<p>Hi {{{{CUSTOMER_NAME}}}},</p>
<p>
This is a friendly reminder that your appointment at <strong>{{{{BUSINESS_NAME}}}}</strong>
is in <strong>{time_before}</strong>.
</p>
<div class="highlight-box">
<div class="info-row">
<span class="info-label">📅 Date:</span>
<span>{{{{APPOINTMENT_DATE}}}}</span>
</div>
<div class="info-row">
<span class="info-label">🕐 Time:</span>
<span>{{{{APPOINTMENT_TIME}}}}</span>
</div>
<div class="info-row">
<span class="info-label">💼 Service:</span>
<span>{{{{APPOINTMENT_SERVICE}}}}</span>
</div>
</div>
<p>
We recommend arriving 5-10 minutes early to ensure a smooth check-in.
</p>
<p>
<strong>Need to reschedule?</strong><br>
Please contact us as soon as possible if you need to make any changes.
</p>
<p>See you soon!</p>
<p><strong>The {{{{BUSINESS_NAME}}}} Team</strong></p>
''' + self.get_email_wrapper_end()
def get_appointment_reminder_text(self, time_before):
return f'''Reminder: Your Appointment is Coming Up!
Hi {{{{CUSTOMER_NAME}}}},
This is a friendly reminder that your appointment at {{{{BUSINESS_NAME}}}} is in {time_before}.
APPOINTMENT DETAILS
-------------------
Date: {{{{APPOINTMENT_DATE}}}}
Time: {{{{APPOINTMENT_TIME}}}}
Service: {{{{APPOINTMENT_SERVICE}}}}
We recommend arriving 5-10 minutes early to ensure a smooth check-in.
Need to reschedule?
Please contact us as soon as possible if you need to make any changes.
See you soon!
The {{{{BUSINESS_NAME}}}} Team
---
{{{{BUSINESS_NAME}}}}
{{{{BUSINESS_EMAIL}}}}
{{{{BUSINESS_PHONE}}}}
'''
def get_appointment_rescheduled_html(self):
return self.get_email_wrapper_start('Appointment Rescheduled') + '''
<h1>Your Appointment Has Been Rescheduled</h1>
<p>Hi {{CUSTOMER_NAME}},</p>
<p>
Your appointment at <strong>{{BUSINESS_NAME}}</strong> has been rescheduled.
Please note the new date and time below.
</p>
<div class="highlight-box">
<div class="info-row">
<span class="info-label">📅 New Date:</span>
<span>{{APPOINTMENT_DATE}}</span>
</div>
<div class="info-row">
<span class="info-label">🕐 New Time:</span>
<span>{{APPOINTMENT_TIME}}</span>
</div>
<div class="info-row">
<span class="info-label">💼 Service:</span>
<span>{{APPOINTMENT_SERVICE}}</span>
</div>
</div>
<p>
If this new time doesn't work for you, please contact us to find an alternative.
</p>
<p>Thank you for your understanding!</p>
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
''' + self.get_email_wrapper_end()
def get_appointment_rescheduled_text(self):
return '''Your Appointment Has Been Rescheduled
Hi {{CUSTOMER_NAME}},
Your appointment at {{BUSINESS_NAME}} has been rescheduled.
NEW APPOINTMENT DETAILS
-----------------------
Date: {{APPOINTMENT_DATE}}
Time: {{APPOINTMENT_TIME}}
Service: {{APPOINTMENT_SERVICE}}
If this new time doesn't work for you, please contact us to find an alternative.
Thank you for your understanding!
The {{BUSINESS_NAME}} Team
---
{{BUSINESS_NAME}}
{{BUSINESS_EMAIL}}
{{BUSINESS_PHONE}}
'''
def get_appointment_cancelled_html(self):
return self.get_email_wrapper_start('Appointment Cancelled') + '''
<h1>Your Appointment Has Been Cancelled</h1>
<p>Hi {{CUSTOMER_NAME}},</p>
<p>
We're writing to confirm that your appointment at <strong>{{BUSINESS_NAME}}</strong>
has been cancelled.
</p>
<div style="background-color: #fef3c7; border: 1px solid #fcd34d; border-radius: 8px; padding: 15px; margin: 20px 0;">
<p style="margin: 0; color: #92400e;">
<strong>Cancelled Appointment:</strong><br>
{{APPOINTMENT_DATE}} at {{APPOINTMENT_TIME}}<br>
Service: {{APPOINTMENT_SERVICE}}
</p>
</div>
<p>
We'd love to see you! Would you like to book a new appointment?
Visit our booking page or give us a call.
</p>
<p>Thank you!</p>
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
''' + self.get_email_wrapper_end()
def get_appointment_cancelled_text(self):
return '''Your Appointment Has Been Cancelled
Hi {{CUSTOMER_NAME}},
We're writing to confirm that your appointment at {{BUSINESS_NAME}} has been cancelled.
CANCELLED APPOINTMENT
---------------------
Date: {{APPOINTMENT_DATE}}
Time: {{APPOINTMENT_TIME}}
Service: {{APPOINTMENT_SERVICE}}
We'd love to see you! Would you like to book a new appointment?
Visit our booking page or give us a call.
Thank you!
The {{BUSINESS_NAME}} Team
---
{{BUSINESS_NAME}}
{{BUSINESS_EMAIL}}
{{BUSINESS_PHONE}}
'''
def get_thank_you_html(self):
return self.get_email_wrapper_start('Thank You') + '''
<h1>Thank You for Visiting!</h1>
<p>Hi {{CUSTOMER_NAME}},</p>
<p>
Thank you for choosing <strong>{{BUSINESS_NAME}}</strong>!
We hope you had a wonderful experience with us.
</p>
<div style="background-color: #ecfdf5; border: 1px solid #6ee7b7; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
<p style="margin: 0; color: #065f46; font-size: 18px;">
⭐ We'd Love Your Feedback! ⭐
</p>
<p style="margin: 10px 0 0 0; color: #047857;">
Your opinion helps us improve and helps others find great services.
</p>
</div>
<p>
Ready to book your next appointment? We're here whenever you need us!
</p>
<p>See you again soon!</p>
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
''' + self.get_email_wrapper_end()
def get_thank_you_text(self):
return '''Thank You for Visiting!
Hi {{CUSTOMER_NAME}},
Thank you for choosing {{BUSINESS_NAME}}! We hope you had a wonderful experience with us.
We'd Love Your Feedback!
Your opinion helps us improve and helps others find great services.
Ready to book your next appointment? We're here whenever you need us!
See you again soon!
The {{BUSINESS_NAME}} Team
---
{{BUSINESS_NAME}}
{{BUSINESS_EMAIL}}
{{BUSINESS_PHONE}}
'''
def get_welcome_customer_html(self):
# TODO: Implement full customer onboarding email flow
# This should include: account setup, booking instructions, loyalty program info
return self.get_email_wrapper_start('Welcome') + '''
<h1>Welcome to {{BUSINESS_NAME}}!</h1>
<p>Hi {{CUSTOMER_NAME}},</p>
<p>
Welcome! We're thrilled to have you join our community at <strong>{{BUSINESS_NAME}}</strong>.
</p>
<div class="highlight-box">
<p style="margin: 0; font-size: 18px; text-align: center;">
🎉 Your account is all set up! 🎉
</p>
</div>
<p>Here's what you can do:</p>
<ul style="color: #4b5563;">
<li>Book appointments online anytime</li>
<li>View and manage your upcoming appointments</li>
<li>Update your contact information and preferences</li>
</ul>
<p>
<strong>Ready to book your first appointment?</strong><br>
We can't wait to see you!
</p>
<p>Best regards,</p>
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
''' + self.get_email_wrapper_end()
def get_welcome_customer_text(self):
# TODO: Implement full customer onboarding email flow
return '''Welcome to {{BUSINESS_NAME}}!
Hi {{CUSTOMER_NAME}},
Welcome! We're thrilled to have you join our community at {{BUSINESS_NAME}}.
Your account is all set up!
Here's what you can do:
- Book appointments online anytime
- View and manage your upcoming appointments
- Update your contact information and preferences
Ready to book your first appointment?
We can't wait to see you!
Best regards,
The {{BUSINESS_NAME}} Team
---
{{BUSINESS_NAME}}
{{BUSINESS_EMAIL}}
{{BUSINESS_PHONE}}
'''
# ========== TICKET NOTIFICATION TEMPLATES ==========
def get_ticket_assigned_html(self):
return self.get_email_wrapper_start('Ticket Assigned') + '''
<h1>New Ticket Assigned to You</h1>
<p>Hi {{ASSIGNEE_NAME}},</p>
<p>
A ticket has been assigned to you and requires your attention.
</p>
<div class="highlight-box">
<div class="info-row">
<span class="info-label">🎫 Ticket:</span>
<span>#{{TICKET_ID}}</span>
</div>
<div class="info-row">
<span class="info-label">📋 Subject:</span>
<span>{{TICKET_SUBJECT}}</span>
</div>
<div class="info-row">
<span class="info-label">⚡ Priority:</span>
<span>{{TICKET_PRIORITY}}</span>
</div>
<div class="info-row">
<span class="info-label">👤 From:</span>
<span>{{TICKET_CUSTOMER_NAME}}</span>
</div>
</div>
<div style="background-color: #f3f4f6; border-radius: 8px; padding: 15px; margin: 20px 0;">
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">Message:</p>
<p style="margin: 0; color: #4b5563;">{{TICKET_MESSAGE}}</p>
</div>
<p style="text-align: center;">
<a href="{{TICKET_URL}}" class="cta-button">View Ticket</a>
</p>
<p>Please respond as soon as possible.</p>
<p><strong>{{BUSINESS_NAME}}</strong></p>
''' + self.get_email_wrapper_end()
def get_ticket_assigned_text(self):
return '''New Ticket Assigned to You
Hi {{ASSIGNEE_NAME}},
A ticket has been assigned to you and requires your attention.
TICKET DETAILS
--------------
Ticket: #{{TICKET_ID}}
Subject: {{TICKET_SUBJECT}}
Priority: {{TICKET_PRIORITY}}
From: {{TICKET_CUSTOMER_NAME}}
Message:
{{TICKET_MESSAGE}}
View ticket: {{TICKET_URL}}
Please respond as soon as possible.
---
{{BUSINESS_NAME}}
'''
def get_ticket_status_changed_html(self):
return self.get_email_wrapper_start('Ticket Status Updated') + '''
<h1>Ticket Status Updated</h1>
<p>Hi {{RECIPIENT_NAME}},</p>
<p>
The status of ticket <strong>#{{TICKET_ID}}</strong> has been updated.
</p>
<div class="highlight-box">
<div class="info-row">
<span class="info-label">🎫 Ticket:</span>
<span>#{{TICKET_ID}}</span>
</div>
<div class="info-row">
<span class="info-label">📋 Subject:</span>
<span>{{TICKET_SUBJECT}}</span>
</div>
<div class="info-row">
<span class="info-label">📊 New Status:</span>
<span>{{TICKET_STATUS}}</span>
</div>
</div>
<p style="text-align: center;">
<a href="{{TICKET_URL}}" class="cta-button">View Ticket</a>
</p>
<p><strong>{{BUSINESS_NAME}}</strong></p>
''' + self.get_email_wrapper_end()
def get_ticket_status_changed_text(self):
return '''Ticket Status Updated
Hi {{RECIPIENT_NAME}},
The status of ticket #{{TICKET_ID}} has been updated.
TICKET DETAILS
--------------
Ticket: #{{TICKET_ID}}
Subject: {{TICKET_SUBJECT}}
New Status: {{TICKET_STATUS}}
View ticket: {{TICKET_URL}}
---
{{BUSINESS_NAME}}
'''
def get_ticket_reply_staff_html(self):
return self.get_email_wrapper_start('New Customer Reply') + '''
<h1>New Reply on Ticket #{{TICKET_ID}}</h1>
<p>Hi {{ASSIGNEE_NAME}},</p>
<p>
<strong>{{TICKET_CUSTOMER_NAME}}</strong> has replied to ticket <strong>#{{TICKET_ID}}</strong>.
</p>
<div style="background-color: #f3f4f6; border-radius: 8px; padding: 15px; margin: 20px 0;">
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">
Subject: {{TICKET_SUBJECT}}
</p>
<p style="margin: 0; color: #4b5563;">{{REPLY_MESSAGE}}</p>
</div>
<p style="text-align: center;">
<a href="{{TICKET_URL}}" class="cta-button">View & Reply</a>
</p>
<p><strong>{{BUSINESS_NAME}}</strong></p>
''' + self.get_email_wrapper_end()
def get_ticket_reply_staff_text(self):
return '''New Reply on Ticket #{{TICKET_ID}}
Hi {{ASSIGNEE_NAME}},
{{TICKET_CUSTOMER_NAME}} has replied to ticket #{{TICKET_ID}}.
Subject: {{TICKET_SUBJECT}}
Reply:
{{REPLY_MESSAGE}}
View & reply: {{TICKET_URL}}
---
{{BUSINESS_NAME}}
'''
def get_ticket_reply_customer_html(self):
return self.get_email_wrapper_start('Response to Your Request') + '''
<h1>We've Responded to Your Request</h1>
<p>Hi {{CUSTOMER_NAME}},</p>
<p>
We've replied to your support request.
</p>
<div class="highlight-box">
<div class="info-row">
<span class="info-label">🎫 Ticket:</span>
<span>#{{TICKET_ID}}</span>
</div>
<div class="info-row">
<span class="info-label">📋 Subject:</span>
<span>{{TICKET_SUBJECT}}</span>
</div>
</div>
<div style="background-color: #f3f4f6; border-radius: 8px; padding: 15px; margin: 20px 0;">
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">Our Response:</p>
<p style="margin: 0; color: #4b5563;">{{REPLY_MESSAGE}}</p>
</div>
<p>
<strong>Need to reply?</strong><br>
Simply reply to this email or click the button below.
</p>
<p style="text-align: center;">
<a href="{{TICKET_URL}}" class="cta-button">View Full Conversation</a>
</p>
<p>Thank you for contacting us!</p>
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
''' + self.get_email_wrapper_end()
def get_ticket_reply_customer_text(self):
return '''We've Responded to Your Request
Hi {{CUSTOMER_NAME}},
We've replied to your support request.
TICKET DETAILS
--------------
Ticket: #{{TICKET_ID}}
Subject: {{TICKET_SUBJECT}}
Our Response:
{{REPLY_MESSAGE}}
Need to reply?
Simply reply to this email or visit: {{TICKET_URL}}
Thank you for contacting us!
The {{BUSINESS_NAME}} Team
---
{{BUSINESS_NAME}}
{{BUSINESS_EMAIL}}
{{BUSINESS_PHONE}}
'''
def get_ticket_resolved_html(self):
return self.get_email_wrapper_start('Ticket Resolved') + '''
<h1>Your Request Has Been Resolved</h1>
<p>Hi {{CUSTOMER_NAME}},</p>
<p>
Great news! Your support request has been resolved.
</p>
<div style="background-color: #ecfdf5; border: 1px solid #6ee7b7; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
<p style="margin: 0; font-size: 18px; color: #065f46;">
✅ Ticket #{{TICKET_ID}} - Resolved
</p>
</div>
<div style="background-color: #f3f4f6; border-radius: 8px; padding: 15px; margin: 20px 0;">
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">
Subject: {{TICKET_SUBJECT}}
</p>
<p style="margin: 0; color: #4b5563; font-size: 14px;">
Resolution: {{RESOLUTION_MESSAGE}}
</p>
</div>
<p>
<strong>Not satisfied with the resolution?</strong><br>
You can reopen this ticket by replying to this email within the next 7 days.
</p>
<p style="text-align: center;">
<a href="{{TICKET_URL}}" class="cta-button">View Ticket History</a>
</p>
<p>Thank you for your patience!</p>
<p><strong>The {{BUSINESS_NAME}} Team</strong></p>
''' + self.get_email_wrapper_end()
def get_ticket_resolved_text(self):
return '''Your Request Has Been Resolved
Hi {{CUSTOMER_NAME}},
Great news! Your support request has been resolved.
Ticket #{{TICKET_ID}} - RESOLVED
Subject: {{TICKET_SUBJECT}}
Resolution: {{RESOLUTION_MESSAGE}}
Not satisfied with the resolution?
You can reopen this ticket by replying to this email within the next 7 days.
View ticket history: {{TICKET_URL}}
Thank you for your patience!
The {{BUSINESS_NAME}} Team
---
{{BUSINESS_NAME}}
{{BUSINESS_EMAIL}}
{{BUSINESS_PHONE}}
'''

View File

@@ -6,18 +6,106 @@ from django.core.mail import send_mail
from django.conf import settings
from django.utils import timezone
from django.shortcuts import get_object_or_404
from django.contrib.auth import authenticate
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework.authtoken.models import Token
from .models import User, EmailVerificationToken, StaffInvitation
from .models import User, EmailVerificationToken, StaffInvitation, TrustedDevice
from .mfa_services import mfa_manager
from core.permissions import can_hijack
from rest_framework import serializers
from schedule.models import Resource, ResourceType
@api_view(['POST'])
@permission_classes([AllowAny])
def login_view(request):
"""
Login user with username/email and password.
POST /api/auth/login/
If MFA is enabled:
- Returns mfa_required=True with user_id and available methods
- Frontend should redirect to MFA verification page
If MFA is not enabled or device is trusted:
- Returns access/refresh tokens and user data
"""
username = request.data.get('username', '').strip()
password = request.data.get('password', '')
if not username or not password:
return Response(
{'error': 'Username and password are required'},
status=status.HTTP_400_BAD_REQUEST
)
# Authenticate user (supports username or email)
user = authenticate(request, username=username, password=password)
# If authentication with username failed, try email
if user is None:
try:
user_by_email = User.objects.get(email__iexact=username)
user = authenticate(request, username=user_by_email.username, password=password)
except User.DoesNotExist:
pass
if user is None:
return Response(
{'error': 'Invalid credentials'},
status=status.HTTP_401_UNAUTHORIZED
)
if not user.is_active:
return Response(
{'error': 'Account is disabled'},
status=status.HTTP_401_UNAUTHORIZED
)
# Check if MFA is required
if mfa_manager.requires_mfa(user):
# Check if device is trusted
if mfa_manager.is_device_trusted(user, request):
# Device is trusted, skip MFA
pass
else:
# MFA required
return Response({
'mfa_required': True,
'user_id': user.id,
'mfa_methods': mfa_manager.get_available_methods(user),
'phone_last_4': user.phone[-4:] if user.phone and len(user.phone) >= 4 else None,
})
# No MFA required or device is trusted - complete login
# Create auth token
Token.objects.filter(user=user).delete()
token = Token.objects.create(user=user)
# Update last login IP
client_ip = _get_client_ip(request)
user.last_login_ip = client_ip
user.save(update_fields=['last_login_ip'])
return Response({
'access': token.key,
'refresh': token.key, # For API compatibility
'user': _get_user_data(user),
})
def _get_client_ip(request):
"""Extract client IP from request."""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
return x_forwarded_for.split(',')[0].strip()
return request.META.get('REMOTE_ADDR', '')
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def current_user_view(request):

View File

@@ -0,0 +1,628 @@
"""
MFA API Views
API endpoints for Two-Factor Authentication management:
- Setup/disable MFA
- Send/verify SMS codes
- Setup/verify TOTP (authenticator app)
- Generate/verify backup codes
- MFA challenge during login
"""
import logging
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from .models import MFAVerificationCode, TrustedDevice
from .mfa_services import mfa_manager
logger = logging.getLogger(__name__)
# ============================================================================
# MFA STATUS
# ============================================================================
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def mfa_status(request):
"""
Get current MFA status for the authenticated user.
Returns:
- mfa_enabled: bool
- mfa_method: str
- methods: list of available methods
- phone_last_4: str or null
- backup_codes_count: int
- backup_codes_generated_at: datetime or null
- trusted_devices_count: int
"""
user = request.user
response_data = {
'mfa_enabled': user.mfa_enabled,
'mfa_method': user.mfa_method,
'methods': mfa_manager.get_available_methods(user),
'phone_last_4': user.phone[-4:] if user.phone and len(user.phone) >= 4 else None,
'phone_verified': user.phone_verified,
'totp_verified': user.totp_verified,
'backup_codes_count': len(user.mfa_backup_codes) if user.mfa_backup_codes else 0,
'backup_codes_generated_at': user.mfa_backup_codes_generated_at,
'trusted_devices_count': TrustedDevice.objects.filter(user=user).count(),
}
return Response(response_data)
# ============================================================================
# SMS SETUP
# ============================================================================
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def send_phone_verification(request):
"""
Send a verification code to verify phone number for SMS MFA.
Request body:
- phone: str (phone number to verify)
Returns:
- success: bool
- message: str
"""
user = request.user
phone = request.data.get('phone')
if not phone:
return Response(
{'error': 'Phone number is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Update phone number (not verified yet)
user.phone = phone
user.phone_verified = False
user.save(update_fields=['phone', 'phone_verified'])
# Send verification code
success, message = mfa_manager.send_sms_code(user, purpose='PHONE_VERIFY')
if success:
return Response({
'success': True,
'message': 'Verification code sent'
})
else:
return Response(
{'error': message},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def verify_phone(request):
"""
Verify phone number with the code sent via SMS.
Request body:
- code: str (6-digit code)
Returns:
- success: bool
- message: str
"""
user = request.user
code = request.data.get('code', '').strip()
if not code or len(code) != 6:
return Response(
{'error': 'Invalid code format'},
status=status.HTTP_400_BAD_REQUEST
)
# Find the verification code
verification = MFAVerificationCode.objects.filter(
user=user,
purpose=MFAVerificationCode.Purpose.PHONE_VERIFY,
used=False
).order_by('-created_at').first()
if not verification:
return Response(
{'error': 'No pending verification. Request a new code.'},
status=status.HTTP_400_BAD_REQUEST
)
if verification.verify(code):
# Mark phone as verified
user.phone_verified = True
user.save(update_fields=['phone_verified'])
return Response({
'success': True,
'message': 'Phone number verified'
})
else:
remaining = 5 - verification.attempts
return Response(
{'error': f'Invalid code. {remaining} attempts remaining.'},
status=status.HTTP_400_BAD_REQUEST
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def enable_sms_mfa(request):
"""
Enable SMS as MFA method.
Requires phone to be verified first.
Returns:
- success: bool
- backup_codes: list (if first time enabling MFA)
"""
user = request.user
if not user.phone_verified:
return Response(
{'error': 'Phone number must be verified first'},
status=status.HTTP_400_BAD_REQUEST
)
# Update MFA settings
is_first_mfa = not user.mfa_enabled
user.mfa_enabled = True
if user.mfa_method == 'NONE':
user.mfa_method = 'SMS'
elif user.mfa_method == 'TOTP':
user.mfa_method = 'BOTH'
user.save(update_fields=['mfa_enabled', 'mfa_method'])
response_data = {
'success': True,
'message': 'SMS MFA enabled',
'mfa_method': user.mfa_method,
}
# Generate backup codes if first time enabling MFA
if is_first_mfa:
backup_codes = mfa_manager.generate_backup_codes(user)
response_data['backup_codes'] = backup_codes
response_data['backup_codes_message'] = 'Save these backup codes in a safe place. They can be used if you lose access to your phone.'
return Response(response_data)
# ============================================================================
# TOTP SETUP (Authenticator App)
# ============================================================================
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def setup_totp(request):
"""
Initialize TOTP setup for authenticator app.
Returns:
- secret: str (for manual entry)
- qr_code: str (data URL for QR code)
- provisioning_uri: str (otpauth:// URI)
"""
user = request.user
setup_data = mfa_manager.setup_totp(user)
return Response({
'success': True,
'secret': setup_data['secret'],
'qr_code': setup_data['qr_code'],
'provisioning_uri': setup_data['provisioning_uri'],
'message': 'Scan this QR code with your authenticator app, then enter the code to verify.'
})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def verify_totp_setup(request):
"""
Verify TOTP code to complete authenticator app setup.
Request body:
- code: str (6-digit TOTP code)
Returns:
- success: bool
- backup_codes: list (if first time enabling MFA)
"""
user = request.user
code = request.data.get('code', '').strip()
if not code or len(code) != 6:
return Response(
{'error': 'Invalid code format. Enter the 6-digit code from your authenticator app.'},
status=status.HTTP_400_BAD_REQUEST
)
is_first_mfa = not user.mfa_enabled
if mfa_manager.verify_totp_setup(user, code):
response_data = {
'success': True,
'message': 'Authenticator app configured successfully',
'mfa_method': user.mfa_method,
}
# Generate backup codes if first time enabling MFA
if is_first_mfa:
backup_codes = mfa_manager.generate_backup_codes(user)
response_data['backup_codes'] = backup_codes
response_data['backup_codes_message'] = 'Save these backup codes in a safe place. They can be used if you lose access to your authenticator app.'
return Response(response_data)
else:
return Response(
{'error': 'Invalid code. Make sure your device time is correct and try again.'},
status=status.HTTP_400_BAD_REQUEST
)
# ============================================================================
# BACKUP CODES
# ============================================================================
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def generate_backup_codes(request):
"""
Generate new backup codes (invalidates old ones).
Returns:
- backup_codes: list
"""
user = request.user
if not user.mfa_enabled:
return Response(
{'error': 'MFA must be enabled to generate backup codes'},
status=status.HTTP_400_BAD_REQUEST
)
backup_codes = mfa_manager.generate_backup_codes(user)
return Response({
'success': True,
'backup_codes': backup_codes,
'message': 'New backup codes generated. Previous codes are now invalid.',
'warning': 'These codes will only be shown once. Save them in a safe place!'
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def backup_codes_status(request):
"""
Get backup codes status (count, when generated).
Returns:
- count: int
- generated_at: datetime or null
"""
user = request.user
return Response({
'count': len(user.mfa_backup_codes) if user.mfa_backup_codes else 0,
'generated_at': user.mfa_backup_codes_generated_at,
})
# ============================================================================
# DISABLE MFA
# ============================================================================
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def disable_mfa(request):
"""
Disable MFA for the user.
Requires current password or valid MFA code for security.
Request body:
- password: str (current password)
OR
- mfa_code: str (valid MFA code)
Returns:
- success: bool
"""
user = request.user
password = request.data.get('password')
mfa_code = request.data.get('mfa_code')
# Verify identity
verified = False
if password:
if user.check_password(password):
verified = True
elif mfa_code:
# Try TOTP first, then backup code
if mfa_manager.verify_totp(user, mfa_code):
verified = True
elif mfa_manager.verify_backup_code(user, mfa_code):
verified = True
if not verified:
return Response(
{'error': 'Invalid password or MFA code'},
status=status.HTTP_400_BAD_REQUEST
)
# Disable MFA
mfa_manager.disable_mfa(user)
return Response({
'success': True,
'message': 'Two-factor authentication has been disabled'
})
# ============================================================================
# MFA LOGIN CHALLENGE
# ============================================================================
@api_view(['POST'])
@permission_classes([AllowAny])
def mfa_login_send_code(request):
"""
Send MFA code for login challenge.
Called after initial login when MFA is required.
Request body:
- user_id: int
- method: str ('SMS' or 'TOTP')
Returns:
- success: bool
- message: str
"""
from .models import User
user_id = request.data.get('user_id')
method = request.data.get('method', 'SMS')
if not user_id:
return Response(
{'error': 'User ID is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
return Response(
{'error': 'Invalid user'},
status=status.HTTP_400_BAD_REQUEST
)
if method == 'SMS':
if not user.phone_verified:
return Response(
{'error': 'SMS not available for this user'},
status=status.HTTP_400_BAD_REQUEST
)
success, message = mfa_manager.send_sms_code(user, purpose='LOGIN')
if success:
return Response({
'success': True,
'message': f'Code sent to ***-***-{user.phone[-4:]}',
'method': 'SMS'
})
else:
return Response(
{'error': 'Failed to send SMS. Please try another method.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
elif method == 'TOTP':
return Response({
'success': True,
'message': 'Enter the code from your authenticator app',
'method': 'TOTP'
})
else:
return Response(
{'error': 'Invalid method'},
status=status.HTTP_400_BAD_REQUEST
)
@api_view(['POST'])
@permission_classes([AllowAny])
def mfa_login_verify(request):
"""
Verify MFA code to complete login.
Request body:
- user_id: int
- code: str (6-digit code or backup code)
- method: str ('SMS', 'TOTP', or 'BACKUP')
- trust_device: bool (optional)
Returns:
- success: bool
- access: str (JWT access token)
- refresh: str (JWT refresh token)
- user: dict
"""
from .models import User
from rest_framework_simplejwt.tokens import RefreshToken
user_id = request.data.get('user_id')
code = request.data.get('code', '').strip()
method = request.data.get('method', 'TOTP')
trust_device = request.data.get('trust_device', False)
if not user_id or not code:
return Response(
{'error': 'User ID and code are required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
return Response(
{'error': 'Invalid user'},
status=status.HTTP_400_BAD_REQUEST
)
# Verify the code based on method
verified = False
if method == 'SMS':
verification = MFAVerificationCode.objects.filter(
user=user,
purpose=MFAVerificationCode.Purpose.LOGIN,
method='SMS',
used=False
).order_by('-created_at').first()
if verification and verification.verify(code):
verified = True
elif method == 'TOTP':
if mfa_manager.verify_totp(user, code):
verified = True
elif method == 'BACKUP':
if mfa_manager.verify_backup_code(user, code):
verified = True
if not verified:
return Response(
{'error': 'Invalid verification code'},
status=status.HTTP_400_BAD_REQUEST
)
# MFA verified - complete login
# Trust device if requested
if trust_device:
mfa_manager.trust_device(user, request)
# Generate JWT tokens
refresh = RefreshToken.for_user(user)
# Get subdomain for frontend routing
subdomain = None
if user.tenant:
primary_domain = user.tenant.domains.filter(is_primary=True, is_custom_domain=False).first()
if primary_domain:
subdomain = primary_domain.domain.split('.')[0]
else:
subdomain = user.tenant.schema_name
return Response({
'success': True,
'access': str(refresh.access_token),
'refresh': str(refresh),
'user': {
'id': user.id,
'email': user.email,
'username': user.username,
'first_name': user.first_name,
'last_name': user.last_name,
'full_name': user.full_name,
'role': user.role.lower(),
'business_subdomain': subdomain,
'mfa_enabled': user.mfa_enabled,
}
})
# ============================================================================
# TRUSTED DEVICES
# ============================================================================
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def list_trusted_devices(request):
"""
List all trusted devices for the user.
Returns:
- devices: list
"""
user = request.user
devices = TrustedDevice.objects.filter(user=user)
return Response({
'devices': [
{
'id': device.id,
'name': device.name,
'ip_address': device.ip_address,
'created_at': device.created_at,
'last_used_at': device.last_used_at,
'expires_at': device.expires_at,
'is_current': mfa_manager.device_service.generate_device_hash(
mfa_manager._get_client_ip(request),
request.META.get('HTTP_USER_AGENT', ''),
user.id
) == device.device_hash
}
for device in devices
]
})
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
def revoke_trusted_device(request, device_id):
"""
Revoke trust for a specific device.
Args:
device_id: int
Returns:
- success: bool
"""
user = request.user
try:
device = TrustedDevice.objects.get(id=device_id, user=user)
device.delete()
return Response({'success': True, 'message': 'Device trust revoked'})
except TrustedDevice.DoesNotExist:
return Response(
{'error': 'Device not found'},
status=status.HTTP_404_NOT_FOUND
)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
def revoke_all_trusted_devices(request):
"""
Revoke trust for all devices.
Returns:
- success: bool
- count: int
"""
user = request.user
count = TrustedDevice.objects.filter(user=user).delete()[0]
return Response({
'success': True,
'message': f'{count} device(s) revoked',
'count': count
})

View File

@@ -0,0 +1,752 @@
"""
MFA Services for Two-Factor Authentication
Provides services for:
1. Twilio SMS verification
2. TOTP (Time-based One-Time Password) for authenticator apps
3. Backup code generation and verification
4. Device trust management
"""
import base64
import hashlib
import hmac
import logging
import secrets
import struct
import time
from typing import Optional, Tuple, List
from django.conf import settings
from django.utils import timezone
logger = logging.getLogger(__name__)
# ============================================================================
# TWILIO SMS SERVICE
# ============================================================================
class TwilioSMSService:
"""
Service for sending SMS verification codes via Twilio.
Environment variables required:
- TWILIO_ACCOUNT_SID
- TWILIO_AUTH_TOKEN
- TWILIO_PHONE_NUMBER
"""
def __init__(self):
self.account_sid = getattr(settings, 'TWILIO_ACCOUNT_SID', None)
self.auth_token = getattr(settings, 'TWILIO_AUTH_TOKEN', None)
self.from_number = getattr(settings, 'TWILIO_PHONE_NUMBER', None)
self._client = None
@property
def client(self):
"""Lazy load Twilio client"""
if self._client is None:
try:
from twilio.rest import Client
if self.account_sid and self.auth_token:
self._client = Client(self.account_sid, self.auth_token)
else:
logger.warning("Twilio credentials not configured")
except ImportError:
logger.error("Twilio library not installed. Run: pip install twilio")
return self._client
def is_configured(self) -> bool:
"""Check if Twilio is properly configured"""
return bool(self.account_sid and self.auth_token and self.from_number)
def send_verification_code(self, to_number: str, code: str,
purpose: str = 'verification') -> Tuple[bool, str]:
"""
Send an SMS verification code.
Args:
to_number: Recipient phone number (E.164 format, e.g., +14155551234)
code: The verification code to send
purpose: Purpose description for the message
Returns:
Tuple of (success: bool, message: str)
"""
if not self.is_configured():
return False, "SMS service not configured"
if not self.client:
return False, "Failed to initialize SMS client"
# Format the message
app_name = getattr(settings, 'APP_NAME', 'SmoothSchedule')
message_body = f"Your {app_name} verification code is: {code}\n\nThis code expires in 10 minutes."
try:
message = self.client.messages.create(
body=message_body,
from_=self.from_number,
to=to_number
)
logger.info(f"SMS sent to {to_number[-4:]}, SID: {message.sid}")
return True, message.sid
except Exception as e:
logger.error(f"Failed to send SMS to {to_number[-4:]}: {str(e)}")
return False, str(e)
def format_phone_number(self, phone: str, country_code: str = '+1') -> str:
"""
Format phone number to E.164 format.
Args:
phone: Phone number (various formats accepted)
country_code: Default country code if not provided
Returns:
Phone number in E.164 format (e.g., +14155551234)
"""
# Remove all non-digit characters except leading +
if phone.startswith('+'):
cleaned = '+' + ''.join(filter(str.isdigit, phone[1:]))
else:
cleaned = ''.join(filter(str.isdigit, phone))
# Add country code if not present
if not cleaned.startswith('+'):
if len(cleaned) == 10: # US number without country code
cleaned = country_code + cleaned
elif len(cleaned) == 11 and cleaned.startswith('1'):
cleaned = '+' + cleaned
else:
cleaned = country_code + cleaned
return cleaned
# ============================================================================
# TOTP SERVICE (Authenticator App)
# ============================================================================
class TOTPService:
"""
Service for TOTP (Time-based One-Time Password) generation and verification.
Compatible with Google Authenticator, Authy, Microsoft Authenticator, etc.
Uses HMAC-SHA1 with 6-digit codes and 30-second time steps (RFC 6238).
"""
DIGITS = 6
TIME_STEP = 30 # seconds
DRIFT_TOLERANCE = 1 # Allow 1 step drift in either direction
def __init__(self, issuer: str = None):
self.issuer = issuer or getattr(settings, 'TOTP_ISSUER', 'SmoothSchedule')
def generate_secret(self) -> str:
"""
Generate a new TOTP secret.
Returns:
Base32-encoded secret (32 characters)
"""
# Generate 20 random bytes (160 bits)
secret_bytes = secrets.token_bytes(20)
# Encode as base32 (standard for TOTP)
return base64.b32encode(secret_bytes).decode('utf-8')
def get_provisioning_uri(self, secret: str, email: str) -> str:
"""
Generate the provisioning URI for QR code generation.
Args:
secret: Base32-encoded secret
email: User's email (used as account name)
Returns:
otpauth:// URI for QR code
"""
import urllib.parse
# URL encode the email and issuer
account = urllib.parse.quote(email)
issuer = urllib.parse.quote(self.issuer)
return f"otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&digits={self.DIGITS}"
def generate_qr_code(self, secret: str, email: str) -> str:
"""
Generate a QR code image as a data URL.
Args:
secret: Base32-encoded secret
email: User's email
Returns:
Data URL string (data:image/png;base64,...)
"""
try:
import qrcode
import io
uri = self.get_provisioning_uri(secret, email)
# Generate QR code
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Convert to base64 data URL
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return f"data:image/png;base64,{img_base64}"
except ImportError:
logger.warning("qrcode library not installed. Run: pip install qrcode[pil]")
return ""
def _get_time_counter(self, timestamp: float = None) -> int:
"""Get the time counter for TOTP calculation"""
if timestamp is None:
timestamp = time.time()
return int(timestamp // self.TIME_STEP)
def _generate_code(self, secret: str, counter: int) -> str:
"""
Generate a TOTP code for a given counter value.
Args:
secret: Base32-encoded secret
counter: Time-based counter
Returns:
6-digit TOTP code
"""
# Decode the base32 secret
try:
key = base64.b32decode(secret.upper())
except Exception:
return ""
# Pack the counter as big-endian 8-byte integer
counter_bytes = struct.pack('>Q', counter)
# Generate HMAC-SHA1
hmac_result = hmac.new(key, counter_bytes, hashlib.sha1).digest()
# Dynamic truncation
offset = hmac_result[-1] & 0x0F
truncated = struct.unpack('>I', hmac_result[offset:offset + 4])[0] & 0x7FFFFFFF
# Generate code with leading zeros
code = truncated % (10 ** self.DIGITS)
return str(code).zfill(self.DIGITS)
def generate_code(self, secret: str) -> str:
"""
Generate the current TOTP code.
Args:
secret: Base32-encoded secret
Returns:
Current 6-digit TOTP code
"""
counter = self._get_time_counter()
return self._generate_code(secret, counter)
def verify_code(self, secret: str, code: str, tolerance: int = None) -> bool:
"""
Verify a TOTP code with time drift tolerance.
Args:
secret: Base32-encoded secret
code: The code to verify
tolerance: Number of time steps to check on either side (default: 1)
Returns:
True if code is valid within tolerance window
"""
if tolerance is None:
tolerance = self.DRIFT_TOLERANCE
if not code or len(code) != self.DIGITS:
return False
counter = self._get_time_counter()
# Check current time step and adjacent ones for drift tolerance
for offset in range(-tolerance, tolerance + 1):
expected_code = self._generate_code(secret, counter + offset)
if hmac.compare_digest(expected_code, code):
return True
return False
# ============================================================================
# BACKUP CODES SERVICE
# ============================================================================
class BackupCodesService:
"""
Service for generating and verifying backup/recovery codes.
"""
CODE_LENGTH = 8
CODE_COUNT = 10
def generate_codes(self) -> List[str]:
"""
Generate a set of backup codes.
Returns:
List of 10 backup codes (8 characters each, alphanumeric)
"""
codes = []
for _ in range(self.CODE_COUNT):
# Generate code in format: XXXX-XXXX
part1 = secrets.token_hex(2).upper()
part2 = secrets.token_hex(2).upper()
codes.append(f"{part1}-{part2}")
return codes
def hash_code(self, code: str) -> str:
"""
Hash a backup code for secure storage.
Args:
code: The backup code to hash
Returns:
SHA-256 hash of the code
"""
# Normalize code (remove dashes, uppercase)
normalized = code.replace('-', '').upper()
return hashlib.sha256(normalized.encode()).hexdigest()
def hash_codes(self, codes: List[str]) -> List[str]:
"""
Hash multiple backup codes.
Args:
codes: List of backup codes
Returns:
List of hashed codes
"""
return [self.hash_code(code) for code in codes]
def verify_code(self, code: str, hashed_codes: List[str]) -> Tuple[bool, int]:
"""
Verify a backup code against stored hashes.
Args:
code: The backup code to verify
hashed_codes: List of hashed codes to check against
Returns:
Tuple of (is_valid: bool, index: int)
index is -1 if not found, otherwise the index of the used code
"""
code_hash = self.hash_code(code)
for i, stored_hash in enumerate(hashed_codes):
if hmac.compare_digest(code_hash, stored_hash):
return True, i
return False, -1
# ============================================================================
# DEVICE TRUST SERVICE
# ============================================================================
class DeviceTrustService:
"""
Service for managing trusted device fingerprinting.
"""
def generate_device_hash(self, ip_address: str, user_agent: str,
user_id: int, salt: str = None) -> str:
"""
Generate a hash to identify a device.
Args:
ip_address: Client IP address
user_agent: Browser user agent string
user_id: User's ID
salt: Optional additional salt
Returns:
Device fingerprint hash
"""
# Combine factors for fingerprint
factors = [
str(user_id),
user_agent or '',
# Note: We use a simplified fingerprint. For production,
# consider using more factors like canvas fingerprint, etc.
]
if salt:
factors.append(salt)
combined = '|'.join(factors)
return hashlib.sha256(combined.encode()).hexdigest()
def get_device_name(self, user_agent: str) -> str:
"""
Extract a friendly device name from user agent.
Args:
user_agent: Browser user agent string
Returns:
Human-readable device name
"""
ua = user_agent.lower()
# Detect browser
browser = 'Unknown Browser'
if 'chrome' in ua and 'edg' not in ua:
browser = 'Chrome'
elif 'firefox' in ua:
browser = 'Firefox'
elif 'safari' in ua and 'chrome' not in ua:
browser = 'Safari'
elif 'edg' in ua:
browser = 'Edge'
elif 'opera' in ua or 'opr' in ua:
browser = 'Opera'
# Detect OS
os_name = 'Unknown OS'
if 'windows' in ua:
os_name = 'Windows'
elif 'macintosh' in ua or 'mac os' in ua:
os_name = 'macOS'
elif 'linux' in ua and 'android' not in ua:
os_name = 'Linux'
elif 'android' in ua:
os_name = 'Android'
elif 'iphone' in ua or 'ipad' in ua:
os_name = 'iOS'
return f"{browser} on {os_name}"
# ============================================================================
# MFA MANAGER (Facade)
# ============================================================================
class MFAManager:
"""
Main facade for all MFA operations.
Coordinates between SMS, TOTP, and backup codes.
"""
def __init__(self):
self.sms_service = TwilioSMSService()
self.totp_service = TOTPService()
self.backup_service = BackupCodesService()
self.device_service = DeviceTrustService()
# SMS Methods
def send_sms_code(self, user, purpose: str = 'LOGIN') -> Tuple[bool, str]:
"""
Send an SMS verification code to the user.
Args:
user: User model instance
purpose: Purpose of the code (LOGIN, SETUP, etc.)
Returns:
Tuple of (success: bool, message: str)
"""
from .models import MFAVerificationCode
if not user.phone:
return False, "No phone number on file"
if not self.sms_service.is_configured():
return False, "SMS service not configured"
# Create verification code record
verification = MFAVerificationCode.create_for_user(
user=user,
purpose=purpose,
method='SMS'
)
# Format phone number
phone = self.sms_service.format_phone_number(user.phone)
# Send SMS
success, message = self.sms_service.send_verification_code(
phone,
verification.code,
purpose=purpose.lower()
)
if not success:
verification.used = True
verification.save()
return success, message
# TOTP Methods
def setup_totp(self, user) -> dict:
"""
Initialize TOTP setup for a user.
Args:
user: User model instance
Returns:
Dict with secret, qr_code, and provisioning_uri
"""
secret = self.totp_service.generate_secret()
qr_code = self.totp_service.generate_qr_code(secret, user.email)
uri = self.totp_service.get_provisioning_uri(secret, user.email)
# Store secret temporarily (not verified yet)
user.totp_secret = secret
user.totp_verified = False
user.save(update_fields=['totp_secret', 'totp_verified'])
return {
'secret': secret,
'qr_code': qr_code,
'provisioning_uri': uri,
}
def verify_totp_setup(self, user, code: str) -> bool:
"""
Verify TOTP code during setup.
Args:
user: User model instance
code: The code entered by user
Returns:
True if code is valid and setup is complete
"""
if not user.totp_secret:
return False
if self.totp_service.verify_code(user.totp_secret, code):
user.totp_verified = True
user.mfa_enabled = True
if user.mfa_method == 'NONE':
user.mfa_method = 'TOTP'
elif user.mfa_method == 'SMS':
user.mfa_method = 'BOTH'
user.save(update_fields=['totp_verified', 'mfa_enabled', 'mfa_method'])
return True
return False
def verify_totp(self, user, code: str) -> bool:
"""
Verify a TOTP code during login.
Args:
user: User model instance
code: The code to verify
Returns:
True if code is valid
"""
if not user.totp_secret or not user.totp_verified:
return False
return self.totp_service.verify_code(user.totp_secret, code)
# Backup Codes Methods
def generate_backup_codes(self, user) -> List[str]:
"""
Generate new backup codes for a user.
Args:
user: User model instance
Returns:
List of backup codes (UNHASHED - show to user once!)
"""
codes = self.backup_service.generate_codes()
hashed_codes = self.backup_service.hash_codes(codes)
user.mfa_backup_codes = hashed_codes
user.mfa_backup_codes_generated_at = timezone.now()
user.save(update_fields=['mfa_backup_codes', 'mfa_backup_codes_generated_at'])
return codes
def verify_backup_code(self, user, code: str) -> bool:
"""
Verify and consume a backup code.
Args:
user: User model instance
code: The backup code to verify
Returns:
True if code is valid (and now consumed)
"""
if not user.mfa_backup_codes:
return False
is_valid, index = self.backup_service.verify_code(code, user.mfa_backup_codes)
if is_valid:
# Remove used code
codes = list(user.mfa_backup_codes)
codes.pop(index)
user.mfa_backup_codes = codes
user.save(update_fields=['mfa_backup_codes'])
return True
return False
# Device Trust Methods
def trust_device(self, user, request, trust_days: int = 30):
"""
Trust the current device for future logins.
Args:
user: User model instance
request: HTTP request object
trust_days: Number of days to trust the device
Returns:
TrustedDevice instance
"""
from .models import TrustedDevice
ip = self._get_client_ip(request)
user_agent = request.META.get('HTTP_USER_AGENT', '')
device_hash = self.device_service.generate_device_hash(
ip_address=ip,
user_agent=user_agent,
user_id=user.id
)
device_name = self.device_service.get_device_name(user_agent)
return TrustedDevice.create_or_update(
user=user,
device_hash=device_hash,
name=device_name,
ip_address=ip,
user_agent=user_agent,
trust_days=trust_days
)
def is_device_trusted(self, user, request) -> bool:
"""
Check if the current device is trusted.
Args:
user: User model instance
request: HTTP request object
Returns:
True if device is trusted
"""
from .models import TrustedDevice
ip = self._get_client_ip(request)
user_agent = request.META.get('HTTP_USER_AGENT', '')
device_hash = self.device_service.generate_device_hash(
ip_address=ip,
user_agent=user_agent,
user_id=user.id
)
try:
device = TrustedDevice.objects.get(user=user, device_hash=device_hash)
return device.is_valid()
except TrustedDevice.DoesNotExist:
return False
def _get_client_ip(self, request) -> str:
"""Extract client IP from request"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
return x_forwarded_for.split(',')[0].strip()
return request.META.get('REMOTE_ADDR', '')
# MFA Status
def requires_mfa(self, user) -> bool:
"""
Check if user requires MFA for login.
Args:
user: User model instance
Returns:
True if MFA is required
"""
return user.mfa_enabled and user.mfa_method != 'NONE'
def get_available_methods(self, user) -> List[str]:
"""
Get available MFA methods for user.
Args:
user: User model instance
Returns:
List of available methods ('SMS', 'TOTP', 'BACKUP')
"""
methods = []
if user.mfa_method in ('SMS', 'BOTH') and user.phone:
methods.append('SMS')
if user.mfa_method in ('TOTP', 'BOTH') and user.totp_verified:
methods.append('TOTP')
if user.mfa_backup_codes:
methods.append('BACKUP')
return methods
def disable_mfa(self, user):
"""
Disable all MFA for a user.
Args:
user: User model instance
"""
user.mfa_enabled = False
user.mfa_method = 'NONE'
user.totp_secret = ''
user.totp_verified = False
user.mfa_backup_codes = []
user.mfa_backup_codes_generated_at = None
user.save(update_fields=[
'mfa_enabled', 'mfa_method', 'totp_secret',
'totp_verified', 'mfa_backup_codes', 'mfa_backup_codes_generated_at'
])
# Remove all trusted devices
from .models import TrustedDevice
TrustedDevice.objects.filter(user=user).delete()
# Create a singleton instance for easy access
mfa_manager = MFAManager()

View File

@@ -0,0 +1,87 @@
# Generated by Django 5.2.8 on 2025-11-29 18:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0007_add_is_sandbox_to_user'),
]
operations = [
migrations.AddField(
model_name='user',
name='mfa_backup_codes',
field=models.JSONField(blank=True, default=list, help_text='List of hashed backup codes for account recovery'),
),
migrations.AddField(
model_name='user',
name='mfa_backup_codes_generated_at',
field=models.DateTimeField(blank=True, help_text='When backup codes were last generated', null=True),
),
migrations.AddField(
model_name='user',
name='mfa_enabled',
field=models.BooleanField(default=False, help_text='Whether two-factor authentication is enabled for this user'),
),
migrations.AddField(
model_name='user',
name='mfa_method',
field=models.CharField(choices=[('NONE', 'None'), ('SMS', 'SMS (Twilio)'), ('TOTP', 'Authenticator App'), ('BOTH', 'Both SMS and Authenticator')], default='NONE', help_text='Preferred 2FA method', max_length=20),
),
migrations.AddField(
model_name='user',
name='phone_verified',
field=models.BooleanField(default=False, help_text='Whether user has verified their phone number'),
),
migrations.AddField(
model_name='user',
name='totp_secret',
field=models.CharField(blank=True, help_text='Encrypted TOTP secret for authenticator apps', max_length=64),
),
migrations.AddField(
model_name='user',
name='totp_verified',
field=models.BooleanField(default=False, help_text='Whether TOTP setup has been verified'),
),
migrations.CreateModel(
name='MFAVerificationCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(help_text='6-digit verification code', max_length=6)),
('purpose', models.CharField(choices=[('LOGIN', 'Login Verification'), ('SETUP', 'MFA Setup'), ('PHONE_VERIFY', 'Phone Verification'), ('DISABLE', 'Disable MFA')], default='LOGIN', max_length=20)),
('method', models.CharField(choices=[('SMS', 'SMS'), ('TOTP', 'TOTP')], default='SMS', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('used', models.BooleanField(default=False)),
('attempts', models.IntegerField(default=0, help_text='Number of failed verification attempts')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mfa_codes', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['user', 'purpose', 'used'], name='users_mfave_user_id_6b7cf0_idx'), models.Index(fields=['expires_at'], name='users_mfave_expires_b8c650_idx')],
},
),
migrations.CreateModel(
name='TrustedDevice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('device_hash', models.CharField(help_text='Hash of device fingerprint (IP + User-Agent + other factors)', max_length=64)),
('name', models.CharField(blank=True, help_text="User-friendly device name (e.g., 'Chrome on MacBook')", max_length=100)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_used_at', models.DateTimeField(auto_now=True)),
('expires_at', models.DateTimeField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trusted_devices', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-last_used_at'],
'indexes': [models.Index(fields=['user', 'device_hash'], name='users_trust_user_id_a68866_idx'), models.Index(fields=['expires_at'], name='users_trust_expires_d87de2_idx')],
'unique_together': {('user', 'device_hash')},
},
),
]

View File

@@ -69,8 +69,48 @@ class User(AbstractUser):
# Additional profile fields
phone = models.CharField(max_length=20, blank=True)
phone_verified = models.BooleanField(
default=False,
help_text="Whether user has verified their phone number"
)
job_title = models.CharField(max_length=100, blank=True)
# Two-Factor Authentication (2FA/MFA) fields
mfa_enabled = models.BooleanField(
default=False,
help_text="Whether two-factor authentication is enabled for this user"
)
mfa_method = models.CharField(
max_length=20,
choices=[
('NONE', 'None'),
('SMS', 'SMS (Twilio)'),
('TOTP', 'Authenticator App'),
('BOTH', 'Both SMS and Authenticator'),
],
default='NONE',
help_text="Preferred 2FA method"
)
totp_secret = models.CharField(
max_length=64,
blank=True,
help_text="Encrypted TOTP secret for authenticator apps"
)
totp_verified = models.BooleanField(
default=False,
help_text="Whether TOTP setup has been verified"
)
mfa_backup_codes = models.JSONField(
default=list,
blank=True,
help_text="List of hashed backup codes for account recovery"
)
mfa_backup_codes_generated_at = models.DateTimeField(
null=True,
blank=True,
help_text="When backup codes were last generated"
)
# Role-specific permissions (stored as JSON for flexibility)
permissions = models.JSONField(
default=dict,
@@ -265,6 +305,132 @@ class EmailVerificationToken(models.Model):
return cls.objects.create(user=user)
class MFAVerificationCode(models.Model):
"""
Temporary verification codes for MFA challenges.
Used for SMS codes and login verification.
"""
class Purpose(models.TextChoices):
LOGIN = 'LOGIN', _('Login Verification')
SETUP = 'SETUP', _('MFA Setup')
PHONE_VERIFY = 'PHONE_VERIFY', _('Phone Verification')
DISABLE = 'DISABLE', _('Disable MFA')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='mfa_codes')
code = models.CharField(max_length=6, help_text="6-digit verification code")
purpose = models.CharField(max_length=20, choices=Purpose.choices, default=Purpose.LOGIN)
method = models.CharField(
max_length=10,
choices=[('SMS', 'SMS'), ('TOTP', 'TOTP')],
default='SMS'
)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used = models.BooleanField(default=False)
attempts = models.IntegerField(default=0, help_text="Number of failed verification attempts")
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['user', 'purpose', 'used']),
models.Index(fields=['expires_at']),
]
def save(self, *args, **kwargs):
if not self.code:
# Generate 6-digit code
import random
self.code = ''.join([str(random.randint(0, 9)) for _ in range(6)])
if not self.expires_at:
# Default expiration: 10 minutes
self.expires_at = timezone.now() + timedelta(minutes=10)
super().save(*args, **kwargs)
def is_valid(self):
"""Check if code is still valid"""
if self.used:
return False
if timezone.now() > self.expires_at:
return False
if self.attempts >= 5: # Max 5 attempts
return False
return True
def verify(self, code):
"""Verify the code and mark as used if correct"""
if not self.is_valid():
return False
if self.code != code:
self.attempts += 1
self.save(update_fields=['attempts'])
return False
self.used = True
self.save(update_fields=['used'])
return True
@classmethod
def create_for_user(cls, user, purpose=Purpose.LOGIN, method='SMS'):
"""Create a new verification code, invalidating old ones"""
# Invalidate old codes for same purpose
cls.objects.filter(user=user, purpose=purpose, used=False).update(used=True)
return cls.objects.create(user=user, purpose=purpose, method=method)
class TrustedDevice(models.Model):
"""
Tracks trusted devices to skip MFA on subsequent logins.
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='trusted_devices')
device_hash = models.CharField(
max_length=64,
help_text="Hash of device fingerprint (IP + User-Agent + other factors)"
)
name = models.CharField(
max_length=100,
blank=True,
help_text="User-friendly device name (e.g., 'Chrome on MacBook')"
)
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(auto_now=True)
expires_at = models.DateTimeField()
class Meta:
ordering = ['-last_used_at']
unique_together = ['user', 'device_hash']
indexes = [
models.Index(fields=['user', 'device_hash']),
models.Index(fields=['expires_at']),
]
def save(self, *args, **kwargs):
if not self.expires_at:
# Default trust period: 30 days
self.expires_at = timezone.now() + timedelta(days=30)
super().save(*args, **kwargs)
def is_valid(self):
"""Check if device trust is still valid"""
return timezone.now() < self.expires_at
@classmethod
def create_or_update(cls, user, device_hash, name='', ip_address=None, user_agent='', trust_days=30):
"""Create or update a trusted device"""
expires_at = timezone.now() + timedelta(days=trust_days)
device, created = cls.objects.update_or_create(
user=user,
device_hash=device_hash,
defaults={
'name': name,
'ip_address': ip_address,
'user_agent': user_agent,
'expires_at': expires_at,
}
)
return device
class StaffInvitation(models.Model):
"""
Invitation for new staff members to join a business.

View File

@@ -0,0 +1,620 @@
"""
Ticket Email Notification Service
Sends email notifications for ticket events using customizable email templates.
Handles:
- Ticket assignment notifications
- Status change notifications
- Reply notifications (to both staff and customers)
- Resolution notifications
Uses email templates from the EmailTemplate model with ticket-specific context variables.
"""
import logging
from typing import Optional, Dict, Any
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.utils import timezone
from .models import Ticket, TicketComment
logger = logging.getLogger(__name__)
class TicketEmailService:
"""
Service for sending ticket-related email notifications.
Uses EmailTemplate model for customizable templates.
Falls back to default templates if none configured.
"""
# Default template names (should match seed_email_templates.py)
TEMPLATE_TICKET_ASSIGNED = 'Ticket Assigned'
TEMPLATE_STATUS_CHANGED = 'Ticket Status Changed'
TEMPLATE_REPLY_STAFF = 'Ticket Reply - Staff Notification'
TEMPLATE_REPLY_CUSTOMER = 'Ticket Reply - Customer Notification'
TEMPLATE_RESOLVED = 'Ticket Resolved'
def __init__(self, ticket: Ticket):
"""
Initialize with a ticket instance.
Args:
ticket: The Ticket model instance
"""
self.ticket = ticket
self.tenant = ticket.tenant
def _get_email_template(self, template_name: str):
"""
Get an email template by name.
Looks up templates in the schedule app's EmailTemplate model.
Returns None if template not found.
"""
try:
from schedule.models import EmailTemplate
return EmailTemplate.objects.filter(
name=template_name,
scope=EmailTemplate.Scope.BUSINESS
).first()
except Exception as e:
logger.warning(f"Could not load email template '{template_name}': {e}")
return None
def _get_base_context(self) -> Dict[str, Any]:
"""
Get base context variables for all ticket emails.
Returns:
Dictionary of context variables for template rendering
"""
# Build ticket URL
base_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:5173')
if self.tenant:
ticket_url = f"{base_url}/tickets/{self.ticket.id}"
else:
ticket_url = f"{base_url}/platform/tickets/{self.ticket.id}"
# Get business context if tenant exists
business_name = self.tenant.name if self.tenant else 'SmoothSchedule Platform'
business_email = getattr(self.tenant, 'contact_email', '') if self.tenant else settings.DEFAULT_FROM_EMAIL
business_phone = getattr(self.tenant, 'phone', '') if self.tenant else ''
# Get creator/customer info
creator = self.ticket.creator
customer_name = creator.get_full_name() if creator else 'Customer'
customer_email = creator.email if creator else ''
return {
# Business context
'BUSINESS_NAME': business_name,
'BUSINESS_EMAIL': business_email,
'BUSINESS_PHONE': business_phone,
# Customer context
'CUSTOMER_NAME': customer_name,
'CUSTOMER_EMAIL': customer_email,
# Ticket context
'TICKET_ID': str(self.ticket.id),
'TICKET_SUBJECT': self.ticket.subject,
'TICKET_MESSAGE': self.ticket.description,
'TICKET_STATUS': self.ticket.get_status_display(),
'TICKET_PRIORITY': self.ticket.get_priority_display(),
'TICKET_CUSTOMER_NAME': customer_name,
'TICKET_URL': ticket_url,
# Date/time
'TODAY': timezone.now().strftime('%B %d, %Y'),
'NOW': timezone.now().strftime('%B %d, %Y at %I:%M %p'),
}
def _render_template_variables(self, text: str, context: Dict[str, Any]) -> str:
"""
Replace {{VARIABLE}} placeholders with actual values.
Args:
text: Template text with {{VARIABLE}} placeholders
context: Dictionary of variable values
Returns:
Text with variables replaced
"""
import re
def replace_var(match):
var_name = match.group(1)
return str(context.get(var_name, match.group(0)))
return re.sub(r'\{\{(\w+)\}\}', replace_var, text)
def _send_email(
self,
to_email: str,
subject: str,
html_content: str,
text_content: str,
reply_to: Optional[str] = None
) -> bool:
"""
Send an email with both HTML and plain text versions.
Args:
to_email: Recipient email address
subject: Email subject
html_content: HTML email body
text_content: Plain text email body
reply_to: Optional Reply-To address for threading
Returns:
True if email sent successfully, False otherwise
"""
if not to_email:
logger.warning("Cannot send email: no recipient address")
return False
try:
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
# Create email message
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=from_email,
to=[to_email],
)
# Add HTML version
if html_content:
msg.attach_alternative(html_content, 'text/html')
# Add Reply-To header with ticket ID for inbound processing
if reply_to:
msg.reply_to = [reply_to]
else:
# Generate reply-to with ticket ID for threading
# Try to get domain from TicketEmailSettings first, then fall back to settings
reply_domain = None
try:
from .models import TicketEmailSettings
email_settings = TicketEmailSettings.get_instance()
if email_settings.support_email_domain:
reply_domain = email_settings.support_email_domain
except Exception:
pass
if not reply_domain:
reply_domain = getattr(settings, 'SUPPORT_EMAIL_DOMAIN', 'smoothschedule.com')
# Format: support+ticket-{id}@domain.com
msg.reply_to = [f"support+ticket-{self.ticket.id}@{reply_domain}"]
# Add headers for email threading
msg.extra_headers = {
'X-Ticket-ID': str(self.ticket.id),
'X-Ticket-Type': self.ticket.ticket_type,
}
msg.send(fail_silently=False)
logger.info(f"Sent ticket email to {to_email}: {subject}")
return True
except Exception as e:
logger.error(f"Failed to send ticket email to {to_email}: {e}")
return False
def send_assignment_notification(self) -> bool:
"""
Send notification when ticket is assigned to someone.
Sends email to the assignee with ticket details.
Returns:
True if email sent successfully
"""
if not self.ticket.assignee:
logger.warning(f"Ticket {self.ticket.id} has no assignee")
return False
assignee = self.ticket.assignee
if not assignee.email:
logger.warning(f"Assignee {assignee.id} has no email address")
return False
context = self._get_base_context()
context['ASSIGNEE_NAME'] = assignee.get_full_name() or assignee.email
context['RECIPIENT_NAME'] = context['ASSIGNEE_NAME']
# Try to get custom template
template = self._get_email_template(self.TEMPLATE_TICKET_ASSIGNED)
if template:
subject = self._render_template_variables(template.subject, context)
html_content = self._render_template_variables(template.html_content, context)
text_content = self._render_template_variables(template.text_content, context)
else:
# Fallback to default
subject = f"[Ticket #{self.ticket.id}] You have been assigned: {self.ticket.subject}"
text_content = self._get_default_assignment_text(context)
html_content = ''
return self._send_email(
to_email=assignee.email,
subject=subject,
html_content=html_content,
text_content=text_content
)
def send_status_change_notification(self, old_status: str, notify_customer: bool = True) -> bool:
"""
Send notification when ticket status changes.
Args:
old_status: Previous status value
notify_customer: Whether to notify the ticket creator
Returns:
True if email sent successfully
"""
if not notify_customer or not self.ticket.creator or not self.ticket.creator.email:
return False
context = self._get_base_context()
context['RECIPIENT_NAME'] = self.ticket.creator.get_full_name() or 'Customer'
context['OLD_STATUS'] = dict(Ticket.Status.choices).get(old_status, old_status)
template = self._get_email_template(self.TEMPLATE_STATUS_CHANGED)
if template:
subject = self._render_template_variables(template.subject, context)
html_content = self._render_template_variables(template.html_content, context)
text_content = self._render_template_variables(template.text_content, context)
else:
subject = f"[Ticket #{self.ticket.id}] Status updated: {self.ticket.get_status_display()}"
text_content = self._get_default_status_change_text(context)
html_content = ''
return self._send_email(
to_email=self.ticket.creator.email,
subject=subject,
html_content=html_content,
text_content=text_content
)
def send_reply_notification_to_staff(self, comment: TicketComment) -> bool:
"""
Send notification to assigned staff when customer replies.
Args:
comment: The TicketComment that was just created
Returns:
True if email sent successfully
"""
if not self.ticket.assignee or not self.ticket.assignee.email:
logger.info(f"No assignee to notify for ticket {self.ticket.id}")
return False
# Don't notify if the assignee wrote the comment
if comment.author == self.ticket.assignee:
return False
context = self._get_base_context()
context['ASSIGNEE_NAME'] = self.ticket.assignee.get_full_name() or self.ticket.assignee.email
context['REPLY_MESSAGE'] = comment.comment_text
template = self._get_email_template(self.TEMPLATE_REPLY_STAFF)
if template:
subject = self._render_template_variables(template.subject, context)
html_content = self._render_template_variables(template.html_content, context)
text_content = self._render_template_variables(template.text_content, context)
else:
subject = f"[Ticket #{self.ticket.id}] New reply from customer: {self.ticket.subject}"
text_content = self._get_default_reply_staff_text(context)
html_content = ''
return self._send_email(
to_email=self.ticket.assignee.email,
subject=subject,
html_content=html_content,
text_content=text_content
)
def send_reply_notification_to_customer(self, comment: TicketComment) -> bool:
"""
Send notification to customer when staff replies.
Supports both registered users (creator) and external email senders.
Args:
comment: The TicketComment that was just created
Returns:
True if email sent successfully
"""
# Determine recipient email - either from creator or external_email
recipient_email = None
recipient_name = None
if self.ticket.creator and self.ticket.creator.email:
recipient_email = self.ticket.creator.email
recipient_name = self.ticket.creator.get_full_name() or self.ticket.creator.email
elif self.ticket.external_email:
recipient_email = self.ticket.external_email
recipient_name = self.ticket.external_name or self.ticket.external_email
if not recipient_email:
logger.info(f"No recipient email for ticket {self.ticket.id}")
return False
# Don't notify if the customer/external sender wrote the comment
if comment.author and comment.author == self.ticket.creator:
return False
# Also check if comment was from external sender matching ticket's external_email
if comment.external_author_email and self.ticket.external_email:
if comment.external_author_email.lower() == self.ticket.external_email.lower():
return False
# Don't send internal comments to customers
if comment.is_internal:
return False
context = self._get_base_context()
context['REPLY_MESSAGE'] = comment.comment_text
context['CUSTOMER_NAME'] = recipient_name
template = self._get_email_template(self.TEMPLATE_REPLY_CUSTOMER)
if template:
subject = self._render_template_variables(template.subject, context)
html_content = self._render_template_variables(template.html_content, context)
text_content = self._render_template_variables(template.text_content, context)
else:
business_name = context['BUSINESS_NAME']
subject = f"[Ticket #{self.ticket.id}] {business_name} has responded to your request"
text_content = self._get_default_reply_customer_text(context)
html_content = ''
return self._send_email(
to_email=recipient_email,
subject=subject,
html_content=html_content,
text_content=text_content
)
def send_resolution_notification(self, resolution_message: str = '') -> bool:
"""
Send notification when ticket is resolved.
Supports both registered users (creator) and external email senders.
Args:
resolution_message: Summary of the resolution
Returns:
True if email sent successfully
"""
# Determine recipient email - either from creator or external_email
recipient_email = None
if self.ticket.creator and self.ticket.creator.email:
recipient_email = self.ticket.creator.email
elif self.ticket.external_email:
recipient_email = self.ticket.external_email
if not recipient_email:
return False
context = self._get_base_context()
context['RESOLUTION_MESSAGE'] = resolution_message or 'Your request has been resolved.'
template = self._get_email_template(self.TEMPLATE_RESOLVED)
if template:
subject = self._render_template_variables(template.subject, context)
html_content = self._render_template_variables(template.html_content, context)
text_content = self._render_template_variables(template.text_content, context)
else:
subject = f"[Ticket #{self.ticket.id}] Your request has been resolved"
text_content = self._get_default_resolution_text(context)
html_content = ''
return self._send_email(
to_email=recipient_email,
subject=subject,
html_content=html_content,
text_content=text_content
)
# ========== Default Text Templates (fallback) ==========
def _get_default_assignment_text(self, context: Dict[str, Any]) -> str:
return f"""New Ticket Assigned to You
Hi {context['ASSIGNEE_NAME']},
A ticket has been assigned to you and requires your attention.
TICKET DETAILS
--------------
Ticket: #{context['TICKET_ID']}
Subject: {context['TICKET_SUBJECT']}
Priority: {context['TICKET_PRIORITY']}
From: {context['TICKET_CUSTOMER_NAME']}
Message:
{context['TICKET_MESSAGE']}
View ticket: {context['TICKET_URL']}
Please respond as soon as possible.
---
{context['BUSINESS_NAME']}
"""
def _get_default_status_change_text(self, context: Dict[str, Any]) -> str:
return f"""Ticket Status Updated
Hi {context['RECIPIENT_NAME']},
The status of ticket #{context['TICKET_ID']} has been updated.
TICKET DETAILS
--------------
Ticket: #{context['TICKET_ID']}
Subject: {context['TICKET_SUBJECT']}
New Status: {context['TICKET_STATUS']}
View ticket: {context['TICKET_URL']}
---
{context['BUSINESS_NAME']}
"""
def _get_default_reply_staff_text(self, context: Dict[str, Any]) -> str:
return f"""New Reply on Ticket #{context['TICKET_ID']}
Hi {context['ASSIGNEE_NAME']},
{context['TICKET_CUSTOMER_NAME']} has replied to ticket #{context['TICKET_ID']}.
Subject: {context['TICKET_SUBJECT']}
Reply:
{context['REPLY_MESSAGE']}
View & reply: {context['TICKET_URL']}
---
{context['BUSINESS_NAME']}
"""
def _get_default_reply_customer_text(self, context: Dict[str, Any]) -> str:
return f"""We've Responded to Your Request
Hi {context['CUSTOMER_NAME']},
We've replied to your support request.
TICKET DETAILS
--------------
Ticket: #{context['TICKET_ID']}
Subject: {context['TICKET_SUBJECT']}
Our Response:
{context['REPLY_MESSAGE']}
Need to reply?
Simply reply to this email or visit: {context['TICKET_URL']}
Thank you for contacting us!
The {context['BUSINESS_NAME']} Team
---
{context['BUSINESS_NAME']}
{context['BUSINESS_EMAIL']}
{context['BUSINESS_PHONE']}
"""
def _get_default_resolution_text(self, context: Dict[str, Any]) -> str:
return f"""Your Request Has Been Resolved
Hi {context['CUSTOMER_NAME']},
Great news! Your support request has been resolved.
Ticket #{context['TICKET_ID']} - RESOLVED
Subject: {context['TICKET_SUBJECT']}
Resolution: {context['RESOLUTION_MESSAGE']}
Not satisfied with the resolution?
You can reopen this ticket by replying to this email within the next 7 days.
View ticket history: {context['TICKET_URL']}
Thank you for your patience!
The {context['BUSINESS_NAME']} Team
---
{context['BUSINESS_NAME']}
{context['BUSINESS_EMAIL']}
{context['BUSINESS_PHONE']}
"""
# ========== Convenience Functions ==========
def notify_ticket_assigned(ticket: Ticket) -> bool:
"""
Send notification when a ticket is assigned.
Args:
ticket: The Ticket that was assigned
Returns:
True if notification sent successfully
"""
service = TicketEmailService(ticket)
return service.send_assignment_notification()
def notify_ticket_status_changed(ticket: Ticket, old_status: str) -> bool:
"""
Send notification when ticket status changes.
Args:
ticket: The Ticket with updated status
old_status: Previous status value
Returns:
True if notification sent successfully
"""
service = TicketEmailService(ticket)
return service.send_status_change_notification(old_status)
def notify_ticket_reply(ticket: Ticket, comment: TicketComment) -> tuple:
"""
Send reply notifications to appropriate parties.
Determines whether to notify staff or customer based on who
authored the comment.
Args:
ticket: The Ticket being replied to
comment: The TicketComment that was created
Returns:
Tuple of (staff_notified, customer_notified)
"""
service = TicketEmailService(ticket)
# If comment is from the ticket creator (customer), notify staff
# If comment is from staff (assignee or other), notify customer
is_customer_reply = comment.author == ticket.creator
staff_notified = False
customer_notified = False
if is_customer_reply:
staff_notified = service.send_reply_notification_to_staff(comment)
else:
customer_notified = service.send_reply_notification_to_customer(comment)
return (staff_notified, customer_notified)
def notify_ticket_resolved(ticket: Ticket, resolution_message: str = '') -> bool:
"""
Send notification when ticket is resolved.
Args:
ticket: The resolved Ticket
resolution_message: Optional resolution summary
Returns:
True if notification sent successfully
"""
service = TicketEmailService(ticket)
return service.send_resolution_notification(resolution_message)

View File

@@ -0,0 +1,716 @@
"""
Inbound Email Receiver Service
Processes incoming emails and creates ticket comments from replies.
Supports:
- IMAP polling for new emails
- Ticket ID extraction from reply-to addresses and subject lines
- Reply text extraction (stripping quoted content)
- User matching by email address
- Audit logging of all incoming emails
Usage:
from tickets.email_receiver import TicketEmailReceiver
receiver = TicketEmailReceiver()
processed_count = receiver.fetch_and_process_emails()
"""
import imaplib
import email
from email.header import decode_header
from email.utils import parseaddr, parsedate_to_datetime
import re
import logging
from typing import Optional, Tuple, List, Dict, Any
from datetime import datetime
from django.utils import timezone
from django.db import transaction
from .models import (
Ticket,
TicketComment,
TicketEmailSettings,
IncomingTicketEmail
)
from smoothschedule.users.models import User
logger = logging.getLogger(__name__)
class TicketEmailReceiver:
"""
Service for receiving and processing inbound ticket emails via IMAP.
"""
# Patterns to extract ticket ID from email addresses
# Matches: support+ticket-123@domain.com, ticket-123@domain.com, etc.
TICKET_ID_PATTERNS = [
r'ticket[_-](\d+)', # ticket-123 or ticket_123
r'\+ticket[_-](\d+)', # +ticket-123 (subaddressing)
r'reply[_-](\d+)', # reply-123
r'\[Ticket #(\d+)\]', # [Ticket #123] in subject
r'#(\d+)', # #123 in subject (less specific)
]
def __init__(self):
"""Initialize the email receiver with settings from database."""
self.settings = TicketEmailSettings.get_instance()
self.connection = None
def is_configured(self) -> bool:
"""Check if email receiving is properly configured."""
return self.settings.is_configured() and self.settings.is_enabled
def connect(self) -> bool:
"""
Establish connection to IMAP server.
Returns:
True if connection successful, False otherwise
"""
if not self.settings.is_configured():
logger.error("IMAP settings not configured")
return False
try:
if self.settings.imap_use_ssl:
self.connection = imaplib.IMAP4_SSL(
self.settings.imap_host,
self.settings.imap_port
)
else:
self.connection = imaplib.IMAP4(
self.settings.imap_host,
self.settings.imap_port
)
self.connection.login(
self.settings.imap_username,
self.settings.imap_password
)
logger.info(f"Connected to IMAP server {self.settings.imap_host}")
return True
except imaplib.IMAP4.error as e:
logger.error(f"IMAP login failed: {e}")
self._update_settings_error(f"IMAP login failed: {e}")
return False
except Exception as e:
logger.error(f"Failed to connect to IMAP server: {e}")
self._update_settings_error(f"Connection failed: {e}")
return False
def disconnect(self):
"""Close IMAP connection."""
if self.connection:
try:
self.connection.logout()
except Exception:
pass
self.connection = None
def fetch_and_process_emails(self) -> int:
"""
Fetch new emails from IMAP and process them.
Returns:
Number of emails successfully processed
"""
if not self.is_configured():
logger.info("Email receiving not configured or disabled")
return 0
if not self.connect():
return 0
processed_count = 0
try:
# Select the inbox folder
self.connection.select(self.settings.imap_folder)
# Search for unread emails
status, messages = self.connection.search(None, 'UNSEEN')
if status != 'OK':
logger.error(f"Failed to search emails: {status}")
return 0
email_ids = messages[0].split()
logger.info(f"Found {len(email_ids)} unread emails")
for email_id in email_ids:
try:
if self._process_single_email(email_id):
processed_count += 1
# Delete email from server if configured
if self.settings.delete_after_processing:
self._delete_email(email_id)
except Exception as e:
logger.error(f"Error processing email {email_id}: {e}")
# Update settings with last check time
self.settings.last_check_at = timezone.now()
self.settings.last_error = ''
self.settings.emails_processed_count += processed_count
self.settings.save()
except Exception as e:
logger.error(f"Error fetching emails: {e}")
self._update_settings_error(str(e))
finally:
self.disconnect()
return processed_count
def _process_single_email(self, email_id: bytes) -> bool:
"""
Process a single email message.
Args:
email_id: IMAP email ID
Returns:
True if email was successfully processed
"""
# Fetch the email
status, msg_data = self.connection.fetch(email_id, '(RFC822)')
if status != 'OK':
logger.error(f"Failed to fetch email {email_id}")
return False
# Parse the email
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
# Extract email data
email_data = self._extract_email_data(msg)
# Check for duplicate (by message ID)
if IncomingTicketEmail.objects.filter(message_id=email_data['message_id']).exists():
logger.info(f"Duplicate email: {email_data['message_id']}")
return False
# Create incoming email record
incoming_email = IncomingTicketEmail.objects.create(
message_id=email_data['message_id'],
from_address=email_data['from_address'],
from_name=email_data['from_name'],
to_address=email_data['to_address'],
subject=email_data['subject'],
body_text=email_data['body_text'],
body_html=email_data['body_html'],
extracted_reply=email_data['extracted_reply'],
raw_headers=email_data['headers'],
email_date=email_data['date'],
ticket_id_from_email=email_data.get('ticket_id', ''),
)
# Try to match to a ticket
ticket = self._find_matching_ticket(email_data)
# Find the user by email address
user = self._find_user_by_email(email_data['from_address'])
if not ticket:
# No matching ticket - create a new one as unassigned, low priority
logger.info(f"No matching ticket for email from {email_data['from_address']}, creating new ticket")
return self._create_new_ticket_from_email(email_data, incoming_email, user)
if not user:
# Check if sender matches ticket creator or assignee
if ticket.creator and ticket.creator.email.lower() == email_data['from_address'].lower():
user = ticket.creator
elif ticket.assignee and ticket.assignee.email.lower() == email_data['from_address'].lower():
user = ticket.assignee
# Check if sender matches the ticket's external email (for tickets from non-registered users)
is_external_sender = False
if not user and ticket.external_email:
if ticket.external_email.lower() == email_data['from_address'].lower():
is_external_sender = True
logger.info(f"Matched external sender {email_data['from_address']} to ticket #{ticket.id}")
if not user and not is_external_sender:
logger.warning(f"Could not match user for email from {email_data['from_address']}")
incoming_email.mark_failed("Could not match sender to a user or external email")
return False
# Create the ticket comment
try:
with transaction.atomic():
comment = TicketComment.objects.create(
ticket=ticket,
author=user, # Will be None for external senders
comment_text=email_data['extracted_reply'] or email_data['body_text'],
is_internal=False,
source=TicketComment.Source.EMAIL,
incoming_email=incoming_email,
# Store external sender info if no user
external_author_email=email_data['from_address'] if is_external_sender else None,
external_author_name=email_data['from_name'] if is_external_sender else '',
)
# Update ticket status if it was awaiting response
if ticket.status == Ticket.Status.AWAITING_RESPONSE:
# If customer/external sender replied, set to open
if user == ticket.creator or is_external_sender:
ticket.status = Ticket.Status.OPEN
ticket.save()
incoming_email.mark_processed(ticket=ticket, user=user)
logger.info(f"Created comment on ticket #{ticket.id} from email")
return True
except Exception as e:
logger.error(f"Failed to create comment: {e}")
incoming_email.mark_failed(str(e))
return False
def _create_new_ticket_from_email(
self,
email_data: Dict[str, Any],
incoming_email: 'IncomingTicketEmail',
user: Optional[User]
) -> bool:
"""
Create a new ticket from an incoming email that doesn't match existing tickets.
Args:
email_data: Extracted email data
incoming_email: The IncomingTicketEmail record
user: The matched user (if found)
Returns:
True if ticket was created successfully
"""
try:
with transaction.atomic():
# Extract subject - use as ticket subject
subject = email_data['subject'] or 'Email Support Request'
# Remove common prefixes like "Re:", "Fwd:", etc.
import re
subject = re.sub(r'^(Re|Fwd|FW|RE|FWD):\s*', '', subject, flags=re.IGNORECASE).strip()
if not subject:
subject = 'Email Support Request'
# Get the email body for description
description = email_data['body_text'] or email_data['extracted_reply'] or ''
if not description and email_data['body_html']:
description = self._html_to_text(email_data['body_html'])
# Create the ticket - unassigned, low priority, platform type
ticket = Ticket.objects.create(
tenant=None, # Platform-level ticket
creator=user, # May be None if sender not in system
assignee=None, # Unassigned
ticket_type=Ticket.TicketType.PLATFORM,
status=Ticket.Status.OPEN,
priority=Ticket.Priority.LOW,
category=Ticket.Category.GENERAL_INQUIRY,
subject=subject[:255], # Truncate to field max length
description=description,
is_sandbox=False,
# Store external sender info if not a registered user
external_email=email_data['from_address'] if not user else None,
external_name=email_data['from_name'] if not user else '',
)
# Create an initial comment with the email content
TicketComment.objects.create(
ticket=ticket,
author=user,
comment_text=email_data['extracted_reply'] or email_data['body_text'] or description,
is_internal=False,
source=TicketComment.Source.EMAIL,
incoming_email=incoming_email,
)
incoming_email.mark_processed(ticket=ticket, user=user)
logger.info(f"Created new ticket #{ticket.id} from email: {subject}")
return True
except Exception as e:
logger.error(f"Failed to create new ticket from email: {e}")
incoming_email.mark_failed(str(e))
return False
def _extract_email_data(self, msg: email.message.Message) -> Dict[str, Any]:
"""
Extract relevant data from an email message.
Args:
msg: Parsed email message
Returns:
Dictionary with extracted email data
"""
# Get message ID
message_id = msg.get('Message-ID', '')
if not message_id:
# Generate a unique ID if none exists
message_id = f"generated-{timezone.now().timestamp()}"
# Parse From header
from_name, from_address = parseaddr(msg.get('From', ''))
from_name = self._decode_header(from_name)
# Parse To header
_, to_address = parseaddr(msg.get('To', ''))
# Get subject
subject = self._decode_header(msg.get('Subject', ''))
# Get date
date_str = msg.get('Date', '')
try:
email_date = parsedate_to_datetime(date_str)
except Exception:
email_date = timezone.now()
# Extract body
body_text, body_html = self._extract_body(msg)
# Extract just the reply (remove quoted text)
extracted_reply = self._extract_reply_text(body_text)
# Try to extract ticket ID from To address or subject
ticket_id = self._extract_ticket_id(to_address, subject)
# Get relevant headers for debugging
headers = {
'from': msg.get('From', ''),
'to': msg.get('To', ''),
'subject': subject,
'date': date_str,
'message-id': message_id,
'in-reply-to': msg.get('In-Reply-To', ''),
'references': msg.get('References', ''),
'x-ticket-id': msg.get('X-Ticket-ID', ''),
}
return {
'message_id': message_id,
'from_name': from_name,
'from_address': from_address.lower(),
'to_address': to_address.lower(),
'subject': subject,
'body_text': body_text,
'body_html': body_html,
'extracted_reply': extracted_reply,
'date': email_date,
'headers': headers,
'ticket_id': ticket_id,
}
def _decode_header(self, header_value: str) -> str:
"""Decode an email header value."""
if not header_value:
return ''
decoded_parts = decode_header(header_value)
result = []
for content, charset in decoded_parts:
if isinstance(content, bytes):
charset = charset or 'utf-8'
try:
content = content.decode(charset)
except Exception:
content = content.decode('utf-8', errors='replace')
result.append(content)
return ''.join(result)
def _extract_body(self, msg: email.message.Message) -> Tuple[str, str]:
"""
Extract text and HTML body from email.
Returns:
Tuple of (text_body, html_body)
"""
text_body = ''
html_body = ''
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
content_disposition = str(part.get('Content-Disposition', ''))
# Skip attachments
if 'attachment' in content_disposition:
continue
try:
body = part.get_payload(decode=True)
if body:
charset = part.get_content_charset() or 'utf-8'
body = body.decode(charset, errors='replace')
if content_type == 'text/plain':
text_body = body
elif content_type == 'text/html':
html_body = body
except Exception as e:
logger.warning(f"Error extracting email body part: {e}")
else:
# Single part message
content_type = msg.get_content_type()
try:
body = msg.get_payload(decode=True)
if body:
charset = msg.get_content_charset() or 'utf-8'
body = body.decode(charset, errors='replace')
if content_type == 'text/plain':
text_body = body
elif content_type == 'text/html':
html_body = body
except Exception as e:
logger.warning(f"Error extracting email body: {e}")
# If no text body but have HTML, try to extract text from HTML
if not text_body and html_body:
text_body = self._html_to_text(html_body)
return text_body, html_body
def _html_to_text(self, html: str) -> str:
"""Convert HTML to plain text (basic implementation)."""
import re
# Remove script and style elements
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
# Replace <br> and <p> with newlines
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'</p>', '\n\n', text, flags=re.IGNORECASE)
# Remove all other HTML tags
text = re.sub(r'<[^>]+>', '', text)
# Decode HTML entities
import html as html_module
text = html_module.unescape(text)
# Clean up whitespace
text = re.sub(r'\n\s*\n', '\n\n', text)
text = text.strip()
return text
def _extract_reply_text(self, text: str) -> str:
"""
Extract just the reply portion, removing quoted text.
Handles common reply formats:
- Gmail: "On Date, Name <email> wrote:"
- Outlook: "From: Name" / "-----Original Message-----"
- Apple Mail: "On Date, at Time, Name wrote:"
- Generic: Lines starting with ">"
"""
if not text:
return ''
lines = text.split('\n')
reply_lines = []
# Patterns that indicate start of quoted content
quote_patterns = [
r'^On .+ wrote:$', # Gmail style
r'^On .+, at .+, .+ wrote:$', # Apple Mail style
r'^From:.*', # Outlook style header
r'^-{3,}\s*Original Message\s*-{3,}', # Outlook separator
r'^_{3,}', # Underscores separator
r'^\*From:\*', # Formatted From:
r'^Sent from my ', # Mobile signatures
r'^Get Outlook for ', # Outlook signature
]
for i, line in enumerate(lines):
stripped = line.strip()
# Check if this line starts quoted content
is_quote_start = False
for pattern in quote_patterns:
if re.match(pattern, stripped, re.IGNORECASE):
is_quote_start = True
break
if is_quote_start:
# Stop here - everything after is quoted
break
# Skip lines that are just quote markers
if stripped.startswith('>'):
continue
reply_lines.append(line)
# Join and clean up
reply = '\n'.join(reply_lines)
# Remove trailing whitespace and signatures
reply = re.sub(r'\n\s*--\s*\n.*$', '', reply, flags=re.DOTALL)
reply = reply.strip()
return reply
def _extract_ticket_id(self, to_address: str, subject: str) -> str:
"""
Extract ticket ID from email address or subject.
Args:
to_address: The To address of the email
subject: The email subject
Returns:
Ticket ID as string, or empty string if not found
"""
# Check To address first (most reliable)
for pattern in self.TICKET_ID_PATTERNS:
match = re.search(pattern, to_address, re.IGNORECASE)
if match:
return match.group(1)
# Check subject line
for pattern in self.TICKET_ID_PATTERNS:
match = re.search(pattern, subject, re.IGNORECASE)
if match:
return match.group(1)
return ''
def _find_matching_ticket(self, email_data: Dict[str, Any]) -> Optional[Ticket]:
"""
Find the ticket this email is replying to.
Args:
email_data: Extracted email data
Returns:
Matching Ticket or None
"""
# First try by extracted ticket ID
ticket_id = email_data.get('ticket_id')
if ticket_id:
try:
ticket = Ticket.objects.get(id=int(ticket_id))
logger.info(f"Matched email to ticket #{ticket_id} by ID")
return ticket
except (Ticket.DoesNotExist, ValueError):
pass
# Try by X-Ticket-ID header (from our outbound emails)
x_ticket_id = email_data['headers'].get('x-ticket-id')
if x_ticket_id:
try:
ticket = Ticket.objects.get(id=int(x_ticket_id))
logger.info(f"Matched email to ticket #{x_ticket_id} by X-Ticket-ID header")
return ticket
except (Ticket.DoesNotExist, ValueError):
pass
# Try by In-Reply-To or References headers
in_reply_to = email_data['headers'].get('in-reply-to', '')
references = email_data['headers'].get('references', '')
for ref in [in_reply_to, references]:
# Look for ticket ID in the reference
for pattern in self.TICKET_ID_PATTERNS:
match = re.search(pattern, ref, re.IGNORECASE)
if match:
try:
ticket_id = int(match.group(1))
ticket = Ticket.objects.get(id=ticket_id)
logger.info(f"Matched email to ticket #{ticket_id} by reference header")
return ticket
except (Ticket.DoesNotExist, ValueError):
pass
# Last resort: find recent ticket by sender email
from_address = email_data['from_address']
try:
# Find most recent open ticket created by this user
user = User.objects.filter(email__iexact=from_address).first()
if user:
ticket = Ticket.objects.filter(
creator=user,
status__in=[
Ticket.Status.OPEN,
Ticket.Status.IN_PROGRESS,
Ticket.Status.AWAITING_RESPONSE
]
).order_by('-created_at').first()
if ticket:
logger.info(f"Matched email to ticket #{ticket.id} by sender's recent ticket")
return ticket
except Exception as e:
logger.warning(f"Error finding ticket by sender: {e}")
return None
def _find_user_by_email(self, email_address: str) -> Optional[User]:
"""Find a user by email address."""
try:
return User.objects.filter(email__iexact=email_address).first()
except Exception:
return None
def _update_settings_error(self, error: str):
"""Update settings with error message."""
self.settings.last_error = error
self.settings.last_check_at = timezone.now()
self.settings.save()
def _delete_email(self, email_id: bytes):
"""
Delete an email from the server.
Args:
email_id: IMAP email ID to delete
"""
try:
# Mark the email for deletion
self.connection.store(email_id, '+FLAGS', '\\Deleted')
# Permanently remove deleted messages
self.connection.expunge()
logger.info(f"Deleted email {email_id} from server")
except Exception as e:
logger.error(f"Failed to delete email {email_id}: {e}")
def test_imap_connection() -> Tuple[bool, str]:
"""
Test IMAP connection with current settings.
Returns:
Tuple of (success, message)
"""
receiver = TicketEmailReceiver()
if not receiver.settings.is_configured():
return False, "IMAP settings not configured"
try:
if receiver.connect():
# Try to select inbox
status, _ = receiver.connection.select(receiver.settings.imap_folder)
receiver.disconnect()
if status == 'OK':
return True, f"Successfully connected to {receiver.settings.imap_host}"
else:
return False, f"Could not access folder '{receiver.settings.imap_folder}'"
else:
return False, "Failed to connect to IMAP server"
except Exception as e:
return False, f"Connection error: {str(e)}"

View File

@@ -0,0 +1,96 @@
"""
Management command to fetch incoming ticket emails.
Usage:
# Single fetch
python manage.py fetch_ticket_emails
# Run as daemon (continuous polling)
python manage.py fetch_ticket_emails --daemon
# Run as daemon with custom interval
python manage.py fetch_ticket_emails --daemon --interval 30
"""
import time
import logging
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Fetch and process incoming ticket emails from IMAP server'
def add_arguments(self, parser):
parser.add_argument(
'--daemon',
action='store_true',
help='Run continuously, polling for new emails'
)
parser.add_argument(
'--interval',
type=int,
default=None,
help='Polling interval in seconds (default: from settings)'
)
def handle(self, *args, **options):
from tickets.email_receiver import TicketEmailReceiver
from tickets.models import TicketEmailSettings
settings = TicketEmailSettings.get_instance()
if not settings.is_configured():
self.stderr.write(self.style.ERROR(
'Email settings not configured. Please configure IMAP settings first.'
))
return
if not settings.is_enabled:
self.stderr.write(self.style.WARNING(
'Email receiving is disabled. Enable it in settings to fetch emails.'
))
if not options['daemon']:
return
receiver = TicketEmailReceiver()
if options['daemon']:
# Daemon mode - continuous polling
interval = options['interval'] or settings.check_interval_seconds
self.stdout.write(self.style.SUCCESS(
f'Starting email fetch daemon (polling every {interval}s)...'
))
while True:
try:
# Refresh settings in case they changed
settings.refresh_from_db()
if settings.is_enabled and settings.is_configured():
processed = receiver.fetch_and_process_emails()
if processed > 0:
self.stdout.write(
f'Processed {processed} emails'
)
else:
logger.debug('Email receiving disabled or not configured')
time.sleep(interval)
except KeyboardInterrupt:
self.stdout.write(self.style.WARNING('\nShutting down...'))
break
except Exception as e:
self.stderr.write(self.style.ERROR(f'Error: {e}'))
logger.exception('Error in email fetch daemon')
time.sleep(interval)
else:
# Single fetch
self.stdout.write('Fetching emails...')
processed = receiver.fetch_and_process_emails()
self.stdout.write(self.style.SUCCESS(
f'Done. Processed {processed} emails.'
))

View File

@@ -0,0 +1,95 @@
# Generated by Django 5.2.8 on 2025-11-29 21:30
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0003_ticket_is_sandbox'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TicketEmailSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('imap_host', models.CharField(blank=True, default='', help_text='IMAP server hostname (e.g., imap.gmail.com)', max_length=255)),
('imap_port', models.IntegerField(default=993, help_text='IMAP server port (993 for SSL, 143 for non-SSL)')),
('imap_use_ssl', models.BooleanField(default=True, help_text='Use SSL/TLS connection')),
('imap_username', models.CharField(blank=True, default='', help_text='IMAP login username (usually email address)', max_length=255)),
('imap_password', models.CharField(blank=True, default='', help_text='IMAP login password or app-specific password', max_length=255)),
('imap_folder', models.CharField(default='INBOX', help_text='IMAP folder to monitor for incoming emails', max_length=100)),
('support_email_address', models.EmailField(blank=True, default='', help_text='Support email address (e.g., support@yourdomain.com)', max_length=254)),
('support_email_domain', models.CharField(blank=True, default='', help_text='Domain for ticket reply addresses (e.g., mail.talova.net)', max_length=255)),
('is_enabled', models.BooleanField(default=False, help_text='Enable inbound email processing')),
('check_interval_seconds', models.IntegerField(default=60, help_text='How often to check for new emails (in seconds)')),
('max_attachment_size_mb', models.IntegerField(default=10, help_text='Maximum attachment size in MB')),
('allowed_attachment_types', models.JSONField(blank=True, default=list, help_text='List of allowed attachment MIME types (empty = all allowed)')),
('last_check_at', models.DateTimeField(blank=True, help_text='When emails were last checked', null=True)),
('last_error', models.TextField(blank=True, default='', help_text='Last error message if any')),
('emails_processed_count', models.IntegerField(default=0, help_text='Total number of emails processed')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Ticket Email Settings',
'verbose_name_plural': 'Ticket Email Settings',
},
),
migrations.AddField(
model_name='ticketcomment',
name='source',
field=models.CharField(choices=[('WEB', 'Web Interface'), ('EMAIL', 'Email Reply'), ('API', 'API')], default='WEB', help_text='How this comment was created (web, email, API).', max_length=10),
),
migrations.CreateModel(
name='IncomingTicketEmail',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message_id', models.CharField(help_text='Email Message-ID header for deduplication', max_length=255, unique=True)),
('from_address', models.EmailField(help_text='Sender email address', max_length=254)),
('from_name', models.CharField(blank=True, default='', help_text='Sender display name', max_length=255)),
('to_address', models.EmailField(help_text='Recipient email address (our support address)', max_length=254)),
('subject', models.CharField(help_text='Email subject line', max_length=500)),
('body_text', models.TextField(blank=True, default='', help_text='Plain text email body')),
('body_html', models.TextField(blank=True, default='', help_text='HTML email body')),
('extracted_reply', models.TextField(blank=True, default='', help_text='Extracted reply text (without quoted content)')),
('raw_headers', models.JSONField(blank=True, default=dict, help_text='Raw email headers for debugging')),
('ticket_id_from_email', models.CharField(blank=True, default='', help_text='Ticket ID extracted from email address or subject', max_length=50)),
('processing_status', models.CharField(choices=[('PENDING', 'Pending'), ('PROCESSED', 'Processed'), ('FAILED', 'Failed'), ('SPAM', 'Marked as Spam'), ('NO_MATCH', 'No Matching Ticket'), ('DUPLICATE', 'Duplicate')], default='PENDING', help_text='Current processing status', max_length=20)),
('error_message', models.TextField(blank=True, default='', help_text='Error details if processing failed')),
('email_date', models.DateTimeField(help_text='Date from email headers')),
('received_at', models.DateTimeField(auto_now_add=True, help_text='When we received/processed this email')),
('processed_at', models.DateTimeField(blank=True, help_text='When processing completed', null=True)),
('matched_user', models.ForeignKey(blank=True, help_text='User matched by email address', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incoming_ticket_emails', to=settings.AUTH_USER_MODEL)),
('ticket', models.ForeignKey(blank=True, help_text='Matched ticket (if found)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incoming_emails', to='tickets.ticket')),
],
options={
'ordering': ['-received_at'],
},
),
migrations.AddField(
model_name='ticketcomment',
name='incoming_email',
field=models.ForeignKey(blank=True, help_text='The incoming email that created this comment.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_comments', to='tickets.incomingticketemail'),
),
migrations.AddIndex(
model_name='incomingticketemail',
index=models.Index(fields=['message_id'], name='tickets_inc_message_b2c788_idx'),
),
migrations.AddIndex(
model_name='incomingticketemail',
index=models.Index(fields=['from_address'], name='tickets_inc_from_ad_989278_idx'),
),
migrations.AddIndex(
model_name='incomingticketemail',
index=models.Index(fields=['processing_status'], name='tickets_inc_process_489f0d_idx'),
),
migrations.AddIndex(
model_name='incomingticketemail',
index=models.Index(fields=['ticket'], name='tickets_inc_ticket__a2c3b3_idx'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-11-29 21:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0004_ticketemailsettings_ticketcomment_source_and_more'),
]
operations = [
migrations.AddField(
model_name='ticketemailsettings',
name='delete_after_processing',
field=models.BooleanField(default=True, help_text='Delete emails from server after successful processing'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.8 on 2025-11-29 21:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0005_add_delete_after_processing'),
]
operations = [
migrations.AddField(
model_name='ticket',
name='external_email',
field=models.EmailField(blank=True, help_text='Email address of external sender (when creator is not a registered user).', max_length=254, null=True),
),
migrations.AddField(
model_name='ticket',
name='external_name',
field=models.CharField(blank=True, default='', help_text='Display name of external sender.', max_length=255),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.8 on 2025-11-29 22:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0006_add_external_email_fields'),
]
operations = [
migrations.AddField(
model_name='ticketcomment',
name='external_author_email',
field=models.EmailField(blank=True, help_text='Email of external author (when author is null).', max_length=254, null=True),
),
migrations.AddField(
model_name='ticketcomment',
name='external_author_name',
field=models.CharField(blank=True, default='', help_text='Name of external author (when author is null).', max_length=255),
),
]

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.2.8 on 2025-11-29 23:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0007_add_external_author_to_comment'),
]
operations = [
migrations.AddField(
model_name='ticketemailsettings',
name='smtp_from_email',
field=models.EmailField(blank=True, default='', help_text='From email address for outgoing emails', max_length=254),
),
migrations.AddField(
model_name='ticketemailsettings',
name='smtp_from_name',
field=models.CharField(blank=True, default='', help_text="From name for outgoing emails (e.g., 'SmoothSchedule Support')", max_length=255),
),
migrations.AddField(
model_name='ticketemailsettings',
name='smtp_host',
field=models.CharField(blank=True, default='', help_text='SMTP server hostname (e.g., smtp.gmail.com)', max_length=255),
),
migrations.AddField(
model_name='ticketemailsettings',
name='smtp_password',
field=models.CharField(blank=True, default='', help_text='SMTP login password or app-specific password', max_length=255),
),
migrations.AddField(
model_name='ticketemailsettings',
name='smtp_port',
field=models.IntegerField(default=587, help_text='SMTP server port (587 for TLS, 465 for SSL, 25 for non-secure)'),
),
migrations.AddField(
model_name='ticketemailsettings',
name='smtp_use_ssl',
field=models.BooleanField(default=False, help_text='Use SSL/TLS encryption (usually for port 465)'),
),
migrations.AddField(
model_name='ticketemailsettings',
name='smtp_use_tls',
field=models.BooleanField(default=True, help_text='Use STARTTLS encryption'),
),
migrations.AddField(
model_name='ticketemailsettings',
name='smtp_username',
field=models.CharField(blank=True, default='', help_text='SMTP login username (usually email address)', max_length=255),
),
]

View File

@@ -120,6 +120,19 @@ class Ticket(models.Model):
help_text="ID of the related appointment for customer inquiry tickets."
)
# External email sender (for tickets created from email by non-users)
external_email = models.EmailField(
blank=True,
null=True,
help_text="Email address of external sender (when creator is not a registered user)."
)
external_name = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Display name of external sender."
)
# SLA tracking
due_at = models.DateTimeField(
null=True,
@@ -272,6 +285,12 @@ class TicketComment(models.Model):
"""
Represents a comment or update on a support ticket.
"""
class Source(models.TextChoices):
WEB = 'WEB', _('Web Interface')
EMAIL = 'EMAIL', _('Email Reply')
API = 'API', _('API')
ticket = models.ForeignKey(
Ticket,
on_delete=models.CASCADE,
@@ -291,9 +310,384 @@ class TicketComment(models.Model):
default=False,
help_text="If true, this comment is only visible to internal staff/platform admins."
)
source = models.CharField(
max_length=10,
choices=Source.choices,
default=Source.WEB,
help_text="How this comment was created (web, email, API)."
)
# Link to incoming email if created from email
incoming_email = models.ForeignKey(
'IncomingTicketEmail',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_comments',
help_text="The incoming email that created this comment."
)
# External author info (for comments from non-registered users via email)
external_author_email = models.EmailField(
null=True,
blank=True,
help_text="Email of external author (when author is null)."
)
external_author_name = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Name of external author (when author is null)."
)
class Meta:
ordering = ['created_at']
@property
def author_display_name(self):
"""Return the display name for the comment author."""
if self.author:
return self.author.get_full_name() or self.author.email
elif self.external_author_name:
return self.external_author_name
elif self.external_author_email:
return self.external_author_email
return 'Unknown'
@property
def author_email(self):
"""Return the email for the comment author."""
if self.author:
return self.author.email
return self.external_author_email
def __str__(self):
return f"Comment on Ticket #{self.ticket.id} by {self.author.email} at {self.created_at.strftime('%Y-%m-%d %H:%M')}"
author_str = self.author.email if self.author else (self.external_author_email or 'Unknown')
return f"Comment on Ticket #{self.ticket.id} by {author_str} at {self.created_at.strftime('%Y-%m-%d %H:%M')}"
class TicketEmailSettings(models.Model):
"""
Configuration for inbound and outbound email processing.
Singleton model - one per system (platform-wide settings).
"""
# IMAP server settings (inbound)
imap_host = models.CharField(
max_length=255,
blank=True,
default='',
help_text="IMAP server hostname (e.g., imap.gmail.com)"
)
imap_port = models.IntegerField(
default=993,
help_text="IMAP server port (993 for SSL, 143 for non-SSL)"
)
imap_use_ssl = models.BooleanField(
default=True,
help_text="Use SSL/TLS connection"
)
imap_username = models.CharField(
max_length=255,
blank=True,
default='',
help_text="IMAP login username (usually email address)"
)
imap_password = models.CharField(
max_length=255,
blank=True,
default='',
help_text="IMAP login password or app-specific password"
)
imap_folder = models.CharField(
max_length=100,
default='INBOX',
help_text="IMAP folder to monitor for incoming emails"
)
# SMTP server settings (outbound)
smtp_host = models.CharField(
max_length=255,
blank=True,
default='',
help_text="SMTP server hostname (e.g., smtp.gmail.com)"
)
smtp_port = models.IntegerField(
default=587,
help_text="SMTP server port (587 for TLS, 465 for SSL, 25 for non-secure)"
)
smtp_use_tls = models.BooleanField(
default=True,
help_text="Use STARTTLS encryption"
)
smtp_use_ssl = models.BooleanField(
default=False,
help_text="Use SSL/TLS encryption (usually for port 465)"
)
smtp_username = models.CharField(
max_length=255,
blank=True,
default='',
help_text="SMTP login username (usually email address)"
)
smtp_password = models.CharField(
max_length=255,
blank=True,
default='',
help_text="SMTP login password or app-specific password"
)
smtp_from_email = models.EmailField(
blank=True,
default='',
help_text="From email address for outgoing emails"
)
smtp_from_name = models.CharField(
max_length=255,
blank=True,
default='',
help_text="From name for outgoing emails (e.g., 'SmoothSchedule Support')"
)
# Email address configuration
support_email_address = models.EmailField(
blank=True,
default='',
help_text="Support email address (e.g., support@yourdomain.com)"
)
support_email_domain = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Domain for ticket reply addresses (e.g., mail.talova.net)"
)
# Processing settings
is_enabled = models.BooleanField(
default=False,
help_text="Enable inbound email processing"
)
delete_after_processing = models.BooleanField(
default=True,
help_text="Delete emails from server after successful processing"
)
check_interval_seconds = models.IntegerField(
default=60,
help_text="How often to check for new emails (in seconds)"
)
max_attachment_size_mb = models.IntegerField(
default=10,
help_text="Maximum attachment size in MB"
)
allowed_attachment_types = models.JSONField(
default=list,
blank=True,
help_text="List of allowed attachment MIME types (empty = all allowed)"
)
# Status tracking
last_check_at = models.DateTimeField(
null=True,
blank=True,
help_text="When emails were last checked"
)
last_error = models.TextField(
blank=True,
default='',
help_text="Last error message if any"
)
emails_processed_count = models.IntegerField(
default=0,
help_text="Total number of emails processed"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'Ticket Email Settings'
verbose_name_plural = 'Ticket Email Settings'
def __str__(self):
status = "Enabled" if self.is_enabled else "Disabled"
return f"Ticket Email Settings ({status})"
def save(self, *args, **kwargs):
# Ensure only one instance exists (singleton)
self.pk = 1
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
# Prevent deletion
pass
@classmethod
def get_instance(cls):
"""Get or create the singleton instance."""
instance, _ = cls.objects.get_or_create(pk=1)
return instance
def is_imap_configured(self):
"""Check if IMAP (inbound) settings are properly configured."""
return bool(
self.imap_host and
self.imap_username and
self.imap_password
)
def is_smtp_configured(self):
"""Check if SMTP (outbound) settings are properly configured."""
return bool(
self.smtp_host and
self.smtp_username and
self.smtp_password and
self.smtp_from_email
)
def is_configured(self):
"""Check if email settings are properly configured (both IMAP and SMTP)."""
return self.is_imap_configured() and self.is_smtp_configured()
class IncomingTicketEmail(models.Model):
"""
Logs all incoming emails for ticket replies.
Provides audit trail and helps with debugging.
"""
class ProcessingStatus(models.TextChoices):
PENDING = 'PENDING', _('Pending')
PROCESSED = 'PROCESSED', _('Processed')
FAILED = 'FAILED', _('Failed')
SPAM = 'SPAM', _('Marked as Spam')
NO_MATCH = 'NO_MATCH', _('No Matching Ticket')
DUPLICATE = 'DUPLICATE', _('Duplicate')
# Email metadata
message_id = models.CharField(
max_length=255,
unique=True,
help_text="Email Message-ID header for deduplication"
)
from_address = models.EmailField(
help_text="Sender email address"
)
from_name = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Sender display name"
)
to_address = models.EmailField(
help_text="Recipient email address (our support address)"
)
subject = models.CharField(
max_length=500,
help_text="Email subject line"
)
# Email content
body_text = models.TextField(
blank=True,
default='',
help_text="Plain text email body"
)
body_html = models.TextField(
blank=True,
default='',
help_text="HTML email body"
)
extracted_reply = models.TextField(
blank=True,
default='',
help_text="Extracted reply text (without quoted content)"
)
# Headers for debugging
raw_headers = models.JSONField(
default=dict,
blank=True,
help_text="Raw email headers for debugging"
)
# Ticket matching
ticket = models.ForeignKey(
Ticket,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='incoming_emails',
help_text="Matched ticket (if found)"
)
matched_user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='incoming_ticket_emails',
help_text="User matched by email address"
)
ticket_id_from_email = models.CharField(
max_length=50,
blank=True,
default='',
help_text="Ticket ID extracted from email address or subject"
)
# Processing status
processing_status = models.CharField(
max_length=20,
choices=ProcessingStatus.choices,
default=ProcessingStatus.PENDING,
help_text="Current processing status"
)
error_message = models.TextField(
blank=True,
default='',
help_text="Error details if processing failed"
)
# Timestamps
email_date = models.DateTimeField(
help_text="Date from email headers"
)
received_at = models.DateTimeField(
auto_now_add=True,
help_text="When we received/processed this email"
)
processed_at = models.DateTimeField(
null=True,
blank=True,
help_text="When processing completed"
)
class Meta:
ordering = ['-received_at']
indexes = [
models.Index(fields=['message_id']),
models.Index(fields=['from_address']),
models.Index(fields=['processing_status']),
models.Index(fields=['ticket']),
]
def __str__(self):
return f"Email from {self.from_address}: {self.subject[:50]}..."
def mark_processed(self, ticket=None, user=None):
"""Mark email as successfully processed."""
self.processing_status = self.ProcessingStatus.PROCESSED
self.processed_at = timezone.now()
if ticket:
self.ticket = ticket
if user:
self.matched_user = user
self.save()
def mark_failed(self, error_message: str):
"""Mark email as failed to process."""
self.processing_status = self.ProcessingStatus.FAILED
self.error_message = error_message
self.processed_at = timezone.now()
self.save()
def mark_no_match(self):
"""Mark email as having no matching ticket."""
self.processing_status = self.ProcessingStatus.NO_MATCH
self.processed_at = timezone.now()
self.save()

View File

@@ -1,16 +1,20 @@
from rest_framework import serializers
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, TicketEmailSettings, IncomingTicketEmail
from smoothschedule.users.models import User
from core.models import Tenant
class TicketCommentSerializer(serializers.ModelSerializer):
author_email = serializers.ReadOnlyField(source='author.email')
author_full_name = serializers.ReadOnlyField(source='author.full_name')
source_display = serializers.CharField(source='get_source_display', read_only=True)
class Meta:
model = TicketComment
fields = ['id', 'ticket', 'author', 'author_email', 'author_full_name', 'comment_text', 'created_at', 'is_internal']
read_only_fields = ['id', 'ticket', 'author', 'author_email', 'author_full_name', 'created_at']
fields = [
'id', 'ticket', 'author', 'author_email', 'author_full_name',
'comment_text', 'created_at', 'is_internal', 'source', 'source_display'
]
read_only_fields = ['id', 'ticket', 'author', 'author_email', 'author_full_name', 'created_at', 'source', 'source_display']
class TicketSerializer(serializers.ModelSerializer):
creator_email = serializers.ReadOnlyField(source='creator.email')
@@ -27,10 +31,12 @@ class TicketSerializer(serializers.ModelSerializer):
'assignee', 'assignee_email', 'assignee_full_name',
'ticket_type', 'status', 'priority', 'subject', 'description', 'category',
'related_appointment_id', 'due_at', 'first_response_at', 'is_overdue',
'created_at', 'updated_at', 'resolved_at', 'comments'
'created_at', 'updated_at', 'resolved_at', 'comments',
'external_email', 'external_name'
]
read_only_fields = ['id', 'creator', 'creator_email', 'creator_full_name',
'is_overdue', 'created_at', 'updated_at', 'resolved_at', 'comments']
'is_overdue', 'created_at', 'updated_at', 'resolved_at', 'comments',
'external_email', 'external_name']
def create(self, validated_data):
# Automatically set creator to the requesting user if not provided (e.g., for platform admin creating for tenant)
@@ -73,10 +79,12 @@ class TicketListSerializer(serializers.ModelSerializer):
'assignee', 'assignee_email', 'assignee_full_name',
'ticket_type', 'status', 'priority', 'subject', 'description', 'category',
'related_appointment_id', 'due_at', 'first_response_at', 'is_overdue',
'created_at', 'updated_at', 'resolved_at'
'created_at', 'updated_at', 'resolved_at',
'external_email', 'external_name'
]
read_only_fields = ['id', 'creator', 'creator_email', 'creator_full_name',
'is_overdue', 'created_at', 'updated_at', 'resolved_at']
'is_overdue', 'created_at', 'updated_at', 'resolved_at',
'external_email', 'external_name']
class TicketTemplateSerializer(serializers.ModelSerializer):
@@ -131,3 +139,117 @@ class CannedResponseSerializer(serializers.ModelSerializer):
# Platform admins can create platform-wide responses (tenant=null)
return super().create(validated_data)
class TicketEmailSettingsSerializer(serializers.ModelSerializer):
"""Serializer for ticket email settings (platform-wide configuration)."""
is_configured = serializers.SerializerMethodField()
is_imap_configured = serializers.SerializerMethodField()
is_smtp_configured = serializers.SerializerMethodField()
imap_password_masked = serializers.SerializerMethodField()
smtp_password_masked = serializers.SerializerMethodField()
class Meta:
model = TicketEmailSettings
fields = [
# IMAP settings
'imap_host', 'imap_port', 'imap_use_ssl', 'imap_username',
'imap_password', 'imap_password_masked', 'imap_folder',
# SMTP settings
'smtp_host', 'smtp_port', 'smtp_use_tls', 'smtp_use_ssl', 'smtp_username',
'smtp_password', 'smtp_password_masked', 'smtp_from_email', 'smtp_from_name',
# General settings
'support_email_address', 'support_email_domain',
'is_enabled', 'delete_after_processing', 'check_interval_seconds',
'max_attachment_size_mb', 'allowed_attachment_types',
# Status fields
'last_check_at', 'last_error', 'emails_processed_count',
'is_configured', 'is_imap_configured', 'is_smtp_configured',
'created_at', 'updated_at'
]
read_only_fields = [
'last_check_at', 'last_error', 'emails_processed_count',
'is_configured', 'is_imap_configured', 'is_smtp_configured',
'imap_password_masked', 'smtp_password_masked',
'created_at', 'updated_at'
]
extra_kwargs = {
'imap_password': {'write_only': True},
'smtp_password': {'write_only': True}
}
def get_is_configured(self, obj):
return obj.is_configured()
def get_is_imap_configured(self, obj):
return obj.is_imap_configured()
def get_is_smtp_configured(self, obj):
return obj.is_smtp_configured()
def get_imap_password_masked(self, obj):
if obj.imap_password:
return '********'
return ''
def get_smtp_password_masked(self, obj):
if obj.smtp_password:
return '********'
return ''
class TicketEmailSettingsUpdateSerializer(serializers.ModelSerializer):
"""Serializer for updating email settings (allows partial updates)."""
class Meta:
model = TicketEmailSettings
fields = [
# IMAP settings
'imap_host', 'imap_port', 'imap_use_ssl', 'imap_username',
'imap_password', 'imap_folder',
# SMTP settings
'smtp_host', 'smtp_port', 'smtp_use_tls', 'smtp_use_ssl', 'smtp_username',
'smtp_password', 'smtp_from_email', 'smtp_from_name',
# General settings
'support_email_address', 'support_email_domain',
'is_enabled', 'delete_after_processing', 'check_interval_seconds',
'max_attachment_size_mb', 'allowed_attachment_types',
]
def update(self, instance, validated_data):
# Only update passwords if new ones are provided
if 'imap_password' in validated_data and not validated_data['imap_password']:
validated_data.pop('imap_password')
if 'smtp_password' in validated_data and not validated_data['smtp_password']:
validated_data.pop('smtp_password')
return super().update(instance, validated_data)
class IncomingTicketEmailSerializer(serializers.ModelSerializer):
"""Serializer for incoming email records."""
processing_status_display = serializers.CharField(source='get_processing_status_display', read_only=True)
ticket_subject = serializers.CharField(source='ticket.subject', read_only=True, default='')
class Meta:
model = IncomingTicketEmail
fields = [
'id', 'message_id', 'from_address', 'from_name', 'to_address',
'subject', 'body_text', 'extracted_reply',
'ticket', 'ticket_subject', 'matched_user', 'ticket_id_from_email',
'processing_status', 'processing_status_display', 'error_message',
'email_date', 'received_at', 'processed_at'
]
read_only_fields = fields # All fields are read-only (incoming emails are created by the system)
class IncomingTicketEmailListSerializer(serializers.ModelSerializer):
"""Lighter serializer for listing incoming emails."""
processing_status_display = serializers.CharField(source='get_processing_status_display', read_only=True)
class Meta:
model = IncomingTicketEmail
fields = [
'id', 'from_address', 'from_name', 'subject',
'ticket', 'processing_status', 'processing_status_display',
'email_date', 'received_at'
]

View File

@@ -1,6 +1,7 @@
import logging
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_save
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.utils import timezone
from channels.layers import get_channel_layer
@@ -11,6 +12,9 @@ from smoothschedule.users.models import User
logger = logging.getLogger(__name__)
# Store pre-save state for detecting changes
_ticket_pre_save_state = {}
# Flag to check if notifications app is available
_notifications_available = None
@@ -98,6 +102,25 @@ def get_tenant_managers(tenant):
return User.objects.none()
@receiver(pre_save, sender=Ticket)
def ticket_pre_save_handler(sender, instance, **kwargs):
"""
Capture ticket state before save for change detection.
This allows us to compare old vs new values in post_save
to determine what changed (assignee, status, etc.) for email notifications.
"""
if instance.pk:
try:
old_instance = Ticket.objects.get(pk=instance.pk)
_ticket_pre_save_state[instance.pk] = {
'assignee_id': old_instance.assignee_id,
'status': old_instance.status,
}
except Ticket.DoesNotExist:
pass
@receiver(post_save, sender=Ticket)
def ticket_notification_handler(sender, instance, created, **kwargs):
"""Handle ticket save events and send notifications."""
@@ -112,11 +135,81 @@ def ticket_notification_handler(sender, instance, created, **kwargs):
logger.error(f"Error in ticket_notification_handler for ticket {instance.id}: {e}")
def _send_ticket_email_notification(notification_type, ticket, **kwargs):
"""
Safely send ticket email notifications.
Args:
notification_type: One of 'assigned', 'status_changed', 'resolved'
ticket: The Ticket instance
**kwargs: Additional arguments for the notification function
"""
# Check if email notifications are enabled (default: True)
if not getattr(settings, 'TICKET_EMAIL_NOTIFICATIONS_ENABLED', True):
return
try:
from .email_notifications import (
notify_ticket_assigned,
notify_ticket_status_changed,
notify_ticket_resolved,
)
if notification_type == 'assigned':
notify_ticket_assigned(ticket)
logger.info(f"Sent email: assignment notification for ticket #{ticket.id}")
elif notification_type == 'status_changed':
old_status = kwargs.get('old_status')
notify_ticket_status_changed(ticket, old_status)
logger.info(f"Sent email: status change notification for ticket #{ticket.id}")
elif notification_type == 'resolved':
resolution_message = kwargs.get('resolution_message', '')
notify_ticket_resolved(ticket, resolution_message)
logger.info(f"Sent email: resolution notification for ticket #{ticket.id}")
except ImportError as e:
logger.warning(f"Email notifications module not available: {e}")
except Exception as e:
logger.error(f"Failed to send email notification for ticket #{ticket.id}: {e}")
def _send_comment_email_notification(ticket, comment):
"""
Safely send comment/reply email notifications.
Args:
ticket: The Ticket instance
comment: The TicketComment instance
"""
# Check if email notifications are enabled (default: True)
if not getattr(settings, 'TICKET_EMAIL_NOTIFICATIONS_ENABLED', True):
return
# Don't send emails for internal comments
if comment.is_internal:
return
try:
from .email_notifications import notify_ticket_reply
staff_notified, customer_notified = notify_ticket_reply(ticket, comment)
if staff_notified:
logger.info(f"Sent email: reply notification to staff for ticket #{ticket.id}")
if customer_notified:
logger.info(f"Sent email: reply notification to customer for ticket #{ticket.id}")
except ImportError as e:
logger.warning(f"Email notifications module not available: {e}")
except Exception as e:
logger.error(f"Failed to send reply email notification for ticket #{ticket.id}: {e}")
def _handle_ticket_creation(ticket):
"""Send notifications when a ticket is created."""
try:
creator_name = ticket.creator.full_name if ticket.creator else "Someone"
# Send email notification if ticket is assigned on creation
if ticket.assignee_id:
_send_ticket_email_notification('assigned', ticket)
if ticket.ticket_type == Ticket.TicketType.PLATFORM:
# PLATFORM tickets: Notify platform support team
platform_team = get_platform_support_team()
@@ -190,6 +283,27 @@ def _handle_ticket_creation(ticket):
def _handle_ticket_update(ticket):
"""Send notifications when a ticket is updated."""
try:
# Check for state changes to trigger email notifications
old_state = _ticket_pre_save_state.pop(ticket.pk, None)
if old_state:
# Check for assignee change
if old_state['assignee_id'] != ticket.assignee_id and ticket.assignee_id:
_send_ticket_email_notification('assigned', ticket)
# Check for status change
if old_state['status'] != ticket.status:
# If status changed to RESOLVED or CLOSED, send resolution notification
if ticket.status in [Ticket.Status.RESOLVED, Ticket.Status.CLOSED]:
_send_ticket_email_notification('resolved', ticket)
else:
# Regular status change notification
_send_ticket_email_notification(
'status_changed',
ticket,
old_status=old_state['status']
)
update_message = {
"type": "ticket_update",
"ticket_id": ticket.id,
@@ -244,6 +358,9 @@ def comment_notification_handler(sender, instance, created, **kwargs):
ticket = instance.ticket
author_name = instance.author.full_name if instance.author else "Someone"
# Send email notification for the comment
_send_comment_email_notification(ticket, instance)
# Track first_response_at: when a comment is added by someone other than the ticket creator
if not ticket.first_response_at and instance.author and instance.author != ticket.creator:
try:

View File

@@ -0,0 +1,74 @@
"""
Celery tasks for ticket email processing.
"""
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task(
name='tickets.fetch_incoming_emails',
bind=True,
max_retries=3,
default_retry_delay=60,
autoretry_for=(Exception,),
)
def fetch_incoming_emails(self):
"""
Celery task to fetch and process incoming ticket emails.
This task should be scheduled to run periodically (e.g., every minute)
via Celery Beat.
Example Celery Beat configuration:
CELERY_BEAT_SCHEDULE = {
'fetch-ticket-emails': {
'task': 'tickets.fetch_incoming_emails',
'schedule': 60.0, # Every 60 seconds
},
}
"""
from .email_receiver import TicketEmailReceiver
from .models import TicketEmailSettings
# Check if email receiving is enabled
settings = TicketEmailSettings.get_instance()
if not settings.is_enabled:
logger.debug("Ticket email receiving is disabled")
return {'status': 'disabled', 'processed': 0}
if not settings.is_configured():
logger.warning("Ticket email settings not configured")
return {'status': 'not_configured', 'processed': 0}
# Process emails
receiver = TicketEmailReceiver()
processed_count = receiver.fetch_and_process_emails()
logger.info(f"Processed {processed_count} incoming ticket emails")
return {
'status': 'success',
'processed': processed_count,
}
@shared_task(name='tickets.test_email_connection')
def test_email_connection():
"""
Task to test IMAP connection.
Returns:
Dict with success status and message
"""
from .email_receiver import test_imap_connection
success, message = test_imap_connection()
return {
'success': success,
'message': message,
}

View File

@@ -2,7 +2,10 @@ from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
TicketViewSet, TicketCommentViewSet,
TicketTemplateViewSet, CannedResponseViewSet
TicketTemplateViewSet, CannedResponseViewSet,
TicketEmailSettingsView, TicketEmailTestConnectionView,
TicketEmailTestSmtpView, TicketEmailFetchNowView,
IncomingTicketEmailViewSet
)
app_name = 'tickets'
@@ -25,8 +28,24 @@ templates_router.register(r'', TicketTemplateViewSet, basename='ticket-template'
canned_router = DefaultRouter()
canned_router.register(r'', CannedResponseViewSet, basename='canned-response')
# Router for incoming emails (audit log)
incoming_emails_router = DefaultRouter()
incoming_emails_router.register(r'', IncomingTicketEmailViewSet, basename='incoming-email')
urlpatterns = [
path('', include(router.urls)),
# Email settings endpoints (platform admin only) - must be BEFORE router.urls
path('email-settings/', TicketEmailSettingsView.as_view(), name='email-settings'),
path('email-settings/test-imap/', TicketEmailTestConnectionView.as_view(), name='email-test-imap'),
path('email-settings/test-smtp/', TicketEmailTestSmtpView.as_view(), name='email-test-smtp'),
path('email-settings/fetch-now/', TicketEmailFetchNowView.as_view(), name='email-fetch-now'),
# Incoming emails audit log - must be BEFORE router.urls
path('incoming-emails/', include(incoming_emails_router.urls)),
# Other static paths
path('templates/', include(templates_router.urls)),
path('canned-responses/', include(canned_router.urls)),
# Main tickets router (includes catch-all pattern) - must be LAST
path('', include(router.urls)),
]

View File

@@ -2,15 +2,18 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from django.db.models import Q
from rest_framework.filters import OrderingFilter, SearchFilter
from core.models import Tenant
from smoothschedule.users.models import User
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, TicketEmailSettings, IncomingTicketEmail
from .serializers import (
TicketSerializer, TicketListSerializer, TicketCommentSerializer,
TicketTemplateSerializer, CannedResponseSerializer
TicketTemplateSerializer, CannedResponseSerializer,
TicketEmailSettingsSerializer, TicketEmailSettingsUpdateSerializer,
IncomingTicketEmailSerializer, IncomingTicketEmailListSerializer
)
@@ -102,11 +105,10 @@ class TicketViewSet(viewsets.ModelViewSet):
)
if is_platform_admin(user):
# Platform admins ONLY see PLATFORM tickets (requests from business users)
# These are tickets where business users are asking the platform for help
# Platform admins see ALL PLATFORM tickets
# This includes tickets from business users AND tickets created from inbound emails
queryset = queryset.filter(
ticket_type=Ticket.TicketType.PLATFORM,
tenant__isnull=False # Must have a tenant (from a business user)
ticket_type=Ticket.TicketType.PLATFORM
)
elif is_customer(user):
# Customers can only see tickets they personally created
@@ -361,4 +363,286 @@ class CannedResponseViewSet(viewsets.ModelViewSet):
canned_response.use_count += 1
canned_response.save(update_fields=['use_count'])
serializer = self.get_serializer(canned_response)
return Response(serializer.data)
return Response(serializer.data)
class IsPlatformAdmin(IsAuthenticated):
"""Permission class that only allows platform administrators."""
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False
return is_platform_admin(request.user)
class TicketEmailSettingsView(APIView):
"""
API endpoint for managing ticket email settings (inbound email configuration).
Only accessible by platform administrators.
GET: Retrieve current email settings
PUT/PATCH: Update email settings
"""
permission_classes = [IsPlatformAdmin]
def get(self, request):
"""Get current email settings."""
settings = TicketEmailSettings.get_instance()
serializer = TicketEmailSettingsSerializer(settings)
return Response(serializer.data)
def put(self, request):
"""Update all email settings."""
settings = TicketEmailSettings.get_instance()
serializer = TicketEmailSettingsUpdateSerializer(settings, data=request.data)
if serializer.is_valid():
serializer.save()
# Return full settings with read-only fields
return Response(TicketEmailSettingsSerializer(settings).data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request):
"""Partially update email settings."""
settings = TicketEmailSettings.get_instance()
serializer = TicketEmailSettingsUpdateSerializer(settings, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
# Return full settings with read-only fields
return Response(TicketEmailSettingsSerializer(settings).data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class TicketEmailTestConnectionView(APIView):
"""
API endpoint to test IMAP connection with current settings.
Only accessible by platform administrators.
POST: Test the IMAP connection
"""
permission_classes = [IsPlatformAdmin]
def post(self, request):
"""Test IMAP connection with current settings."""
from .email_receiver import test_imap_connection
success, message = test_imap_connection()
return Response({
'success': success,
'message': message,
}, status=status.HTTP_200_OK if success else status.HTTP_400_BAD_REQUEST)
class TicketEmailTestSmtpView(APIView):
"""
API endpoint to test SMTP connection with current settings.
Only accessible by platform administrators.
POST: Test the SMTP connection
"""
permission_classes = [IsPlatformAdmin]
def post(self, request):
"""Test SMTP connection with current settings."""
import smtplib
import ssl
settings = TicketEmailSettings.get_instance()
if not settings.smtp_host or not settings.smtp_username or not settings.smtp_password:
return Response({
'success': False,
'message': 'SMTP settings not configured. Please provide host, username, and password.',
}, status=status.HTTP_400_BAD_REQUEST)
try:
if settings.smtp_use_ssl:
# SSL connection (typically port 465)
context = ssl.create_default_context()
server = smtplib.SMTP_SSL(
settings.smtp_host,
settings.smtp_port,
context=context,
timeout=10
)
else:
# Regular connection with optional STARTTLS
server = smtplib.SMTP(
settings.smtp_host,
settings.smtp_port,
timeout=10
)
server.ehlo()
if settings.smtp_use_tls:
context = ssl.create_default_context()
server.starttls(context=context)
server.ehlo()
# Authenticate
server.login(settings.smtp_username, settings.smtp_password)
server.quit()
return Response({
'success': True,
'message': f'Successfully connected to SMTP server at {settings.smtp_host}:{settings.smtp_port}',
})
except smtplib.SMTPAuthenticationError as e:
return Response({
'success': False,
'message': f'SMTP authentication failed: {str(e)}',
}, status=status.HTTP_400_BAD_REQUEST)
except smtplib.SMTPConnectError as e:
return Response({
'success': False,
'message': f'Failed to connect to SMTP server: {str(e)}',
}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
return Response({
'success': False,
'message': f'SMTP connection error: {str(e)}',
}, status=status.HTTP_400_BAD_REQUEST)
class TicketEmailFetchNowView(APIView):
"""
API endpoint to manually trigger email fetch.
Only accessible by platform administrators.
POST: Trigger immediate email fetch
"""
permission_classes = [IsPlatformAdmin]
def post(self, request):
"""Manually trigger email fetch."""
from .email_receiver import TicketEmailReceiver
settings = TicketEmailSettings.get_instance()
if not settings.is_imap_configured():
return Response({
'success': False,
'message': 'IMAP settings not configured',
'processed': 0,
}, status=status.HTTP_400_BAD_REQUEST)
receiver = TicketEmailReceiver()
processed_count = receiver.fetch_and_process_emails()
return Response({
'success': True,
'message': f'Successfully fetched and processed {processed_count} emails',
'processed': processed_count,
})
class IncomingTicketEmailViewSet(viewsets.ReadOnlyModelViewSet):
"""
API endpoint for viewing incoming email records (audit log).
Only accessible by platform administrators.
"""
queryset = IncomingTicketEmail.objects.all().select_related('ticket', 'matched_user')
permission_classes = [IsPlatformAdmin]
filter_backends = [OrderingFilter, SearchFilter]
ordering_fields = ['received_at', 'email_date', 'processing_status']
ordering = ['-received_at']
search_fields = ['from_address', 'subject', 'from_name']
def get_serializer_class(self):
if self.action == 'list':
return IncomingTicketEmailListSerializer
return IncomingTicketEmailSerializer
def get_queryset(self):
"""Apply filters from query parameters."""
queryset = super().get_queryset()
# Filter by processing status
status_filter = self.request.query_params.get('status')
if status_filter:
queryset = queryset.filter(processing_status=status_filter)
# Filter by ticket
ticket_id = self.request.query_params.get('ticket')
if ticket_id:
queryset = queryset.filter(ticket_id=ticket_id)
return queryset
@action(detail=True, methods=['post'])
def reprocess(self, request, pk=None):
"""
Attempt to reprocess a failed incoming email.
URL: /api/incoming-emails/{id}/reprocess/
"""
incoming_email = self.get_object()
if incoming_email.processing_status == IncomingTicketEmail.ProcessingStatus.PROCESSED:
return Response({
'success': False,
'message': 'Email was already processed successfully',
}, status=status.HTTP_400_BAD_REQUEST)
# Reset status to pending and try again
incoming_email.processing_status = IncomingTicketEmail.ProcessingStatus.PENDING
incoming_email.error_message = ''
incoming_email.save()
# Try to reprocess
from .email_receiver import TicketEmailReceiver
receiver = TicketEmailReceiver()
# Manually process this email
try:
ticket = receiver._find_matching_ticket({
'from_address': incoming_email.from_address,
'headers': incoming_email.raw_headers,
'ticket_id': incoming_email.ticket_id_from_email,
})
if not ticket:
incoming_email.mark_no_match()
return Response({
'success': False,
'message': 'Could not find matching ticket',
})
user = receiver._find_user_by_email(incoming_email.from_address)
if not user:
if ticket.creator and ticket.creator.email.lower() == incoming_email.from_address.lower():
user = ticket.creator
elif ticket.assignee and ticket.assignee.email.lower() == incoming_email.from_address.lower():
user = ticket.assignee
if not user:
incoming_email.mark_failed('Could not match sender to a user')
return Response({
'success': False,
'message': 'Could not match sender to a user',
})
# Create comment
comment = TicketComment.objects.create(
ticket=ticket,
author=user,
comment_text=incoming_email.extracted_reply or incoming_email.body_text,
is_internal=False,
source=TicketComment.Source.EMAIL,
incoming_email=incoming_email,
)
incoming_email.mark_processed(ticket=ticket, user=user)
return Response({
'success': True,
'message': f'Successfully created comment on ticket #{ticket.id}',
'comment_id': comment.id,
'ticket_id': ticket.id,
})
except Exception as e:
incoming_email.mark_failed(str(e))
return Response({
'success': False,
'message': f'Error reprocessing: {str(e)}',
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

328
smoothschedule/uv.lock generated
View File

@@ -2,6 +2,73 @@ version = 1
revision = 3
requires-python = "==3.13.*"
[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
]
[[package]]
name = "aiohttp"
version = "3.13.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
{ name = "aiosignal" },
{ name = "attrs" },
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139, upload-time = "2025-10-28T20:57:02.455Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082, upload-time = "2025-10-28T20:57:04.784Z" },
{ url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035, upload-time = "2025-10-28T20:57:06.894Z" },
{ url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387, upload-time = "2025-10-28T20:57:08.685Z" },
{ url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314, upload-time = "2025-10-28T20:57:10.693Z" },
{ url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317, upload-time = "2025-10-28T20:57:12.563Z" },
{ url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539, upload-time = "2025-10-28T20:57:14.623Z" },
{ url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597, upload-time = "2025-10-28T20:57:16.399Z" },
{ url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006, upload-time = "2025-10-28T20:57:18.288Z" },
{ url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220, upload-time = "2025-10-28T20:57:20.241Z" },
{ url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570, upload-time = "2025-10-28T20:57:22.253Z" },
{ url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407, upload-time = "2025-10-28T20:57:24.37Z" },
{ url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093, upload-time = "2025-10-28T20:57:26.257Z" },
{ url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084, upload-time = "2025-10-28T20:57:28.349Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987, upload-time = "2025-10-28T20:57:30.233Z" },
{ url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859, upload-time = "2025-10-28T20:57:32.105Z" },
{ url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192, upload-time = "2025-10-28T20:57:34.166Z" },
]
[[package]]
name = "aiohttp-retry"
version = "2.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/61/ebda4d8e3d8cfa1fd3db0fb428db2dd7461d5742cea35178277ad180b033/aiohttp_retry-2.9.1.tar.gz", hash = "sha256:8eb75e904ed4ee5c2ec242fefe85bf04240f685391c4879d8f541d6028ff01f1", size = 13608, upload-time = "2024-11-06T10:44:54.574Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/99/84ba7273339d0f3dfa57901b846489d2e5c2cd731470167757f1935fffbd/aiohttp_retry-2.9.1-py3-none-any.whl", hash = "sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54", size = 9981, upload-time = "2024-11-06T10:44:52.917Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "alabaster"
version = "1.0.0"
@@ -202,6 +269,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]]
name = "channels"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/cb/6fedd9df5972b893a04c8e5d7748873d6480a813e74b0797945bee1f4282/channels-4.0.0.tar.gz", hash = "sha256:0ce53507a7da7b148eaa454526e0e05f7da5e5d1c23440e4886cf146981d8420", size = 24446, upload-time = "2022-10-15T19:12:11.724Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/52/a233dc63996547f171c2013f2d0505dcfa7d0557e7cde8748a2bd70b5a31/channels-4.0.0-py3-none-any.whl", hash = "sha256:2253334ac76f67cba68c2072273f7e0e67dbdac77eeb7e318f511d2f9a53c5e4", size = 28852, upload-time = "2022-10-15T19:12:09.625Z" },
]
[[package]]
name = "channels-redis"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "channels" },
{ name = "msgpack" },
{ name = "redis" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/3b/941efa8e337c3537475926fbf86e8cfe38a919e0f60bb9538b1cff364b8d/channels_redis-4.1.0.tar.gz", hash = "sha256:6bd4f75f4ab4a7db17cee495593ace886d7e914c66f8214a1f247ff6659c073a", size = 20332, upload-time = "2023-03-28T17:58:36.835Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/8f/920556dd928cf2e6f1dcd7898e083e25137f6d90b8d6a6680a991a439d0f/channels_redis-4.1.0-py3-none-any.whl", hash = "sha256:3696f5b9fe367ea495d402ba83d7c3c99e8ca0e1354ff8d913535976ed0abf73", size = 18154, upload-time = "2023-03-28T17:58:34.727Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
@@ -418,6 +513,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
name = "dj-stripe"
version = "2.10.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "stripe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/1d/2cf7839130c185f96f72fcac782040517fb0cf4d4f8c2756debfe08b7485/dj_stripe-2.10.3.tar.gz", hash = "sha256:fe870a93dfcbb7f17432fb1da7291a2222c89b32a6d6fba16cb34ed6b37f1584", size = 132793, upload-time = "2025-10-15T14:38:51.09Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/d7/8acdaf9dfad2c6334cad4620fdb62545914b81a0d6cd847e2bedf12b35a9/dj_stripe-2.10.3-py3-none-any.whl", hash = "sha256:035b8f991429d5a474d1d46e4f3ab36722250fa9dac39b5d835c694c49a19dea", size = 156525, upload-time = "2025-10-15T14:38:49.58Z" },
]
[[package]]
name = "django"
version = "5.2.8"
@@ -834,6 +942,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/ff/ee2f67c0ff146ec98b5df1df637b2bc2d17beeb05df9f427a67bd7a7d79c/flower-2.0.1-py2.py3-none-any.whl", hash = "sha256:9db2c621eeefbc844c8dd88be64aef61e84e2deb29b271e02ab2b5b9f01068e2", size = 383553, upload-time = "2023-08-13T14:37:41.552Z" },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
{ url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
{ url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
{ url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
{ url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
{ url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
{ url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
{ url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
[[package]]
name = "greenlet"
version = "3.2.4"
@@ -1134,6 +1283,68 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" },
]
[[package]]
name = "msgpack"
version = "1.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
{ url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
{ url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
{ url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
{ url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
{ url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
{ url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
{ url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
]
[[package]]
name = "multidict"
version = "6.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" },
{ url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" },
{ url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" },
{ url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" },
{ url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" },
{ url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" },
{ url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" },
{ url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" },
{ url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" },
{ url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" },
{ url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" },
{ url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" },
{ url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" },
{ url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" },
{ url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" },
{ url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" },
{ url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" },
{ url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" },
{ url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" },
{ url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" },
{ url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" },
{ url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" },
{ url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" },
{ url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" },
{ url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" },
{ url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" },
{ url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" },
{ url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" },
{ url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" },
{ url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" },
{ url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" },
{ url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" },
]
[[package]]
name = "mypy"
version = "1.18.2"
@@ -1318,6 +1529,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "propcache"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" },
{ url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" },
{ url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" },
{ url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" },
{ url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" },
{ url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" },
{ url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" },
{ url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" },
{ url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" },
{ url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" },
{ url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" },
{ url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" },
{ url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" },
{ url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" },
{ url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" },
{ url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" },
{ url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" },
{ url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" },
{ url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" },
{ url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" },
{ url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" },
{ url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" },
{ url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" },
{ url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" },
{ url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" },
{ url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" },
{ url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" },
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
]
[[package]]
name = "psycopg"
version = "3.2.13"
@@ -1389,6 +1639,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyjwt"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
]
[[package]]
name = "pytest"
version = "9.0.1"
@@ -1688,7 +1947,10 @@ source = { virtual = "." }
dependencies = [
{ name = "argon2-cffi" },
{ name = "celery" },
{ name = "channels" },
{ name = "channels-redis" },
{ name = "crispy-bootstrap5" },
{ name = "dj-stripe" },
{ name = "django" },
{ name = "django-allauth", extra = ["mfa"] },
{ name = "django-anymail" },
@@ -1713,6 +1975,7 @@ dependencies = [
{ name = "redis" },
{ name = "sentry-sdk" },
{ name = "stripe" },
{ name = "twilio" },
{ name = "whitenoise" },
]
@@ -1745,7 +2008,10 @@ dev = [
requires-dist = [
{ name = "argon2-cffi", specifier = "==25.1.0" },
{ name = "celery", specifier = "==5.5.3" },
{ name = "channels", specifier = "==4.0.0" },
{ name = "channels-redis", specifier = "==4.1.0" },
{ name = "crispy-bootstrap5", specifier = "==2025.6" },
{ name = "dj-stripe", specifier = ">=2.9.0" },
{ name = "django", specifier = "==5.2.8" },
{ name = "django-allauth", extras = ["mfa"], specifier = "==65.13.1" },
{ name = "django-anymail", extras = ["mailgun"], specifier = "==13.1" },
@@ -1770,6 +2036,7 @@ requires-dist = [
{ name = "redis", specifier = "==7.1.0" },
{ name = "sentry-sdk", specifier = "==2.46.0" },
{ name = "stripe", specifier = ">=7.0.0" },
{ name = "twilio", specifier = ">=9.0.0" },
{ name = "whitenoise", specifier = "==6.11.0" },
]
@@ -2021,6 +2288,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
]
[[package]]
name = "twilio"
version = "9.8.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "aiohttp-retry" },
{ name = "pyjwt" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/d7/4ecaec95673eaaddc56c7a6bd8184b6ea3598fed284f6af85fe29e7fc611/twilio-9.8.7.tar.gz", hash = "sha256:5c9658439c4b0fdebd467337b5b8c1abc29e690f93c25caa425bcb8a93ca907a", size = 944065, upload-time = "2025-11-20T04:36:13.154Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/cd/5b18437b7a2e1d69d9a8e6196c8ddb6f42b62a701cb127f8d394a7200761/twilio-9.8.7-py2.py3-none-any.whl", hash = "sha256:ef07646dde58e45f24089ab90e92507ad1167e34a5f798e4d73f322e1348216b", size = 1838762, upload-time = "2025-11-20T04:36:11.351Z" },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20250915"
@@ -2223,3 +2505,49 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/95/8c81ec6b6ebcbf8ac
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/e9/4366332f9295fe0647d7d3251ce18f5615fbcb12d02c79a26f8dba9221b3/whitenoise-6.11.0-py3-none-any.whl", hash = "sha256:b2aeb45950597236f53b5342b3121c5de69c8da0109362aee506ce88e022d258", size = 20197, upload-time = "2025-09-18T09:16:09.754Z" },
]
[[package]]
name = "yarl"
version = "1.22.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" },
{ url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" },
{ url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" },
{ url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" },
{ url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" },
{ url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" },
{ url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" },
{ url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" },
{ url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" },
{ url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
{ url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
{ url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
{ url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
{ url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
{ url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
{ url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" },
{ url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" },
{ url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" },
{ url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" },
{ url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" },
{ url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
{ url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
{ url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
{ url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
{ url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
{ url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
]