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>
133
TODO_EMAIL_AND_MESSAGING.md
Normal 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
|
||||
```
|
||||
|
Before Width: | Height: | Size: 993 B After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 840 B After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 49 KiB |
@@ -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()
|
||||
@@ -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()
|
||||
143
frontend/public/plugin-logos/generate_icons.py
Normal 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()
|
||||
@@ -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()
|
||||
@@ -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]}")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 706 B After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 43 KiB |
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
169
frontend/src/api/ticketEmailSettings.ts
Normal 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;
|
||||
};
|
||||
456
frontend/src/components/EmailTemplateForm.tsx
Normal 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;
|
||||
112
frontend/src/components/EmailTemplateSelector.tsx
Normal 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;
|
||||
@@ -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'}`}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
106
frontend/src/hooks/useTicketEmailSettings.ts
Normal 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 };
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
535
frontend/src/pages/EmailTemplates.tsx
Normal 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;
|
||||
@@ -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}` : '';
|
||||
|
||||
620
frontend/src/pages/MFASetupPage.tsx
Normal 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;
|
||||
431
frontend/src/pages/MFAVerifyPage.tsx
Normal 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;
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
567
smoothschedule/PLAN_EMAIL_TEMPLATES.md
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
68
smoothschedule/core/migrations/0009_add_feature_limits.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
209
smoothschedule/platform_admin/tasks.py
Normal 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}
|
||||
@@ -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>© {% now "Y" %} SmoothSchedule. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>/',
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
38
smoothschedule/schedule/migrations/0023_email_template.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}',
|
||||
}
|
||||
|
||||
@@ -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>© {% now "Y" %} {{ business_name }}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
192
smoothschedule/scripts/generate_placeholder_icons.py
Normal 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()
|
||||
199
smoothschedule/scripts/generate_plugin_icons.py
Normal 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()
|
||||
@@ -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}}
|
||||
'''
|
||||
@@ -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):
|
||||
|
||||
628
smoothschedule/smoothschedule/users/mfa_api_views.py
Normal 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
|
||||
})
|
||||
752
smoothschedule/smoothschedule/users/mfa_services.py
Normal 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()
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
620
smoothschedule/tickets/email_notifications.py
Normal 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)
|
||||
716
smoothschedule/tickets/email_receiver.py
Normal 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)}"
|
||||
0
smoothschedule/tickets/management/__init__.py
Normal 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.'
|
||||
))
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
53
smoothschedule/tickets/migrations/0008_add_smtp_settings.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
74
smoothschedule/tickets/tasks.py
Normal 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,
|
||||
}
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -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
@@ -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" },
|
||||
]
|
||||
|
||||