Added complete plugin documentation with visual mockups and expanded template
variable system with CONTEXT, DATE helpers, and default values.
Backend Changes:
- Extended template_parser.py to support all new template types
- Added PROMPT with default values: {{PROMPT:var|desc|default}}
- Added CONTEXT variables: {{CONTEXT:business_name}}, {{CONTEXT:owner_email}}
- Added DATE helpers: {{DATE:today}}, {{DATE:+7d}}, {{DATE:monday}}
- Implemented date expression evaluation for relative dates
- Updated compile_template to handle all template types
- Added context parameter for business data auto-fill
Frontend Changes:
- Created comprehensive HelpPluginDocs.tsx with Stripe-style API docs
- Added visual mockup of plugin configuration form
- Documented all template types with examples and benefits
- Added Command Reference section with allowed/blocked Python commands
- Documented all HTTP methods (GET, POST, PUT, PATCH, DELETE)
- Added URL whitelisting requirements and approval process
- Created Platform Staff management page with edit modal
- Added can_approve_plugins and can_whitelist_urls permissions
Platform Staff Features:
- List all platform_manager and platform_support users
- Edit user details with role-based permissions
- Superusers can edit anyone
- Platform managers can only edit platform_support users
- Permission cascade: users can only grant permissions they have
- Real-time updates via React Query cache invalidation
Documentation Highlights:
- 4 template types: PROMPT, CONTEXT, DATE, and automatic validation
- Visual form mockup showing exactly what users see
- All allowed control flow (if/elif/else, for, while, try/except, etc.)
- All allowed built-in functions (len, range, min, max, etc.)
- All blocked operations (import, exec, eval, class/function defs)
- Complete HTTP API reference with examples
- URL whitelisting process: contact pluginaccess@smoothschedule.com
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
527 lines
18 KiB
Python
527 lines
18 KiB
Python
"""
|
|
Example: Smart Client Re-engagement Automation
|
|
|
|
This demonstrates a real-world automation that businesses would love:
|
|
Automatically win back customers who haven't booked in a while.
|
|
"""
|
|
|
|
from typing import Any, Dict
|
|
from django.utils import timezone
|
|
from django.core.mail import send_mail
|
|
from django.conf import settings
|
|
from django.db.models import Max, Count, Q
|
|
from datetime import timedelta
|
|
import logging
|
|
|
|
from .plugins import BasePlugin, register_plugin, PluginExecutionError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@register_plugin
|
|
class ClientReengagementPlugin(BasePlugin):
|
|
"""
|
|
Automatically re-engage customers who haven't booked recently.
|
|
|
|
This plugin:
|
|
1. Finds customers who haven't booked in X days
|
|
2. Sends personalized re-engagement emails
|
|
3. Offers optional discount codes
|
|
4. Tracks engagement metrics
|
|
"""
|
|
|
|
name = "client_reengagement"
|
|
display_name = "Client Re-engagement Campaign"
|
|
description = "Automatically reach out to customers who haven't booked recently with personalized offers"
|
|
category = "marketing"
|
|
|
|
config_schema = {
|
|
'days_inactive': {
|
|
'type': 'integer',
|
|
'required': False,
|
|
'default': 60,
|
|
'description': 'Target customers who haven\'t booked in this many days (default: 60)',
|
|
},
|
|
'email_subject': {
|
|
'type': 'string',
|
|
'required': False,
|
|
'default': 'We Miss You! Come Back Soon',
|
|
'description': 'Email subject line',
|
|
},
|
|
'email_message': {
|
|
'type': 'text',
|
|
'required': False,
|
|
'default': '''Hi {customer_name},
|
|
|
|
We noticed it's been a while since your last visit on {last_visit_date}. We'd love to see you again!
|
|
|
|
As a valued customer, we're offering you {discount}% off your next appointment.
|
|
|
|
Book now and use code: {promo_code}
|
|
|
|
Looking forward to seeing you soon!
|
|
{business_name}''',
|
|
'description': 'Email message template (variables: customer_name, last_visit_date, business_name, discount, promo_code)',
|
|
},
|
|
'discount_percentage': {
|
|
'type': 'integer',
|
|
'required': False,
|
|
'default': 15,
|
|
'description': 'Discount percentage to offer (default: 15%)',
|
|
},
|
|
'promo_code_prefix': {
|
|
'type': 'string',
|
|
'required': False,
|
|
'default': 'COMEBACK',
|
|
'description': 'Prefix for generated promo codes (default: COMEBACK)',
|
|
},
|
|
'max_customers_per_run': {
|
|
'type': 'integer',
|
|
'required': False,
|
|
'default': 50,
|
|
'description': 'Maximum customers to contact per execution (prevents spam)',
|
|
},
|
|
'exclude_already_contacted': {
|
|
'type': 'boolean',
|
|
'required': False,
|
|
'default': True,
|
|
'description': 'Skip customers who were already contacted by this campaign',
|
|
},
|
|
'minimum_past_visits': {
|
|
'type': 'integer',
|
|
'required': False,
|
|
'default': 1,
|
|
'description': 'Only target customers with at least this many past visits',
|
|
},
|
|
}
|
|
|
|
def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Execute the re-engagement campaign"""
|
|
|
|
from .models import Event
|
|
from smoothschedule.users.models import User
|
|
|
|
business = context.get('business')
|
|
if not business:
|
|
raise PluginExecutionError("No business context available")
|
|
|
|
# Get configuration
|
|
days_inactive = self.config.get('days_inactive', 60)
|
|
max_customers = self.config.get('max_customers_per_run', 50)
|
|
min_visits = self.config.get('minimum_past_visits', 1)
|
|
|
|
# Calculate cutoff date
|
|
cutoff_date = timezone.now() - timedelta(days=days_inactive)
|
|
|
|
# Find inactive customers
|
|
inactive_customers = self._find_inactive_customers(
|
|
cutoff_date=cutoff_date,
|
|
min_visits=min_visits,
|
|
max_count=max_customers
|
|
)
|
|
|
|
if not inactive_customers:
|
|
return {
|
|
'success': True,
|
|
'message': 'No inactive customers found',
|
|
'data': {
|
|
'customers_contacted': 0,
|
|
'emails_sent': 0,
|
|
}
|
|
}
|
|
|
|
# Send re-engagement emails
|
|
results = {
|
|
'customers_contacted': 0,
|
|
'emails_sent': 0,
|
|
'emails_failed': 0,
|
|
'customers': [],
|
|
}
|
|
|
|
for customer_data in inactive_customers:
|
|
try:
|
|
success = self._send_reengagement_email(
|
|
customer_data=customer_data,
|
|
business=business
|
|
)
|
|
|
|
if success:
|
|
results['emails_sent'] += 1
|
|
results['customers_contacted'] += 1
|
|
results['customers'].append({
|
|
'email': customer_data['email'],
|
|
'last_visit': customer_data['last_visit'].isoformat(),
|
|
'days_since_visit': customer_data['days_since_visit'],
|
|
})
|
|
else:
|
|
results['emails_failed'] += 1
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send re-engagement email to {customer_data['email']}: {e}")
|
|
results['emails_failed'] += 1
|
|
|
|
return {
|
|
'success': True,
|
|
'message': f"Contacted {results['customers_contacted']} inactive customers",
|
|
'data': results
|
|
}
|
|
|
|
def _find_inactive_customers(self, cutoff_date, min_visits, max_count):
|
|
"""Find customers who haven't booked recently"""
|
|
from .models import Event, Participant
|
|
from smoothschedule.users.models import User
|
|
from django.contrib.contenttypes.models import ContentType
|
|
|
|
# Get customer content type
|
|
customer_ct = ContentType.objects.get_for_model(User)
|
|
|
|
# Find customers with their last visit date
|
|
# This query finds all customers who participated in events
|
|
customer_participants = Participant.objects.filter(
|
|
role=Participant.Role.CUSTOMER,
|
|
content_type=customer_ct,
|
|
).values('object_id').annotate(
|
|
last_event_date=Max('event__end_time'),
|
|
total_visits=Count('event', filter=Q(event__status__in=['COMPLETED', 'PAID']))
|
|
).filter(
|
|
last_event_date__lt=cutoff_date, # Last visit before cutoff
|
|
total_visits__gte=min_visits, # Minimum number of visits
|
|
).order_by('last_event_date')[:max_count]
|
|
|
|
# Get customer details
|
|
inactive_customers = []
|
|
for participant_data in customer_participants:
|
|
try:
|
|
customer = User.objects.get(id=participant_data['object_id'])
|
|
|
|
# Skip if no email
|
|
if not customer.email:
|
|
continue
|
|
|
|
days_since_visit = (timezone.now() - participant_data['last_event_date']).days
|
|
|
|
inactive_customers.append({
|
|
'id': customer.id,
|
|
'email': customer.email,
|
|
'name': customer.get_full_name() or customer.username,
|
|
'last_visit': participant_data['last_event_date'],
|
|
'days_since_visit': days_since_visit,
|
|
'total_visits': participant_data['total_visits'],
|
|
})
|
|
except User.DoesNotExist:
|
|
continue
|
|
|
|
return inactive_customers
|
|
|
|
def _send_reengagement_email(self, customer_data, business):
|
|
"""Send personalized re-engagement email to a customer"""
|
|
|
|
# Generate promo code
|
|
promo_prefix = self.config.get('promo_code_prefix', 'COMEBACK')
|
|
promo_code = f"{promo_prefix}{customer_data['id']}"
|
|
|
|
# Format email message
|
|
email_template = self.config.get('email_message', self.config_schema['email_message']['default'])
|
|
|
|
email_body = email_template.format(
|
|
customer_name=customer_data['name'],
|
|
last_visit_date=customer_data['last_visit'].strftime('%B %d, %Y'),
|
|
business_name=business.name if business else 'Our Business',
|
|
discount=self.config.get('discount_percentage', 15),
|
|
promo_code=promo_code,
|
|
)
|
|
|
|
subject = self.config.get('email_subject', 'We Miss You! Come Back Soon')
|
|
|
|
try:
|
|
send_mail(
|
|
subject=subject,
|
|
message=email_body,
|
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
recipient_list=[customer_data['email']],
|
|
fail_silently=False,
|
|
)
|
|
|
|
logger.info(f"Sent re-engagement email to {customer_data['email']} (promo: {promo_code})")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send email to {customer_data['email']}: {e}")
|
|
return False
|
|
|
|
|
|
@register_plugin
|
|
class LowShowRateAlertPlugin(BasePlugin):
|
|
"""
|
|
Alert business owners when no-show rate is unusually high.
|
|
|
|
Helps businesses identify issues early and take corrective action.
|
|
"""
|
|
|
|
name = "low_show_rate_alert"
|
|
display_name = "No-Show Rate Alert"
|
|
description = "Alert when customer no-show rate exceeds threshold"
|
|
category = "monitoring"
|
|
|
|
config_schema = {
|
|
'threshold_percentage': {
|
|
'type': 'integer',
|
|
'required': False,
|
|
'default': 20,
|
|
'description': 'Alert if no-show rate exceeds this percentage (default: 20%)',
|
|
},
|
|
'lookback_days': {
|
|
'type': 'integer',
|
|
'required': False,
|
|
'default': 7,
|
|
'description': 'Analyze appointments from the last N days (default: 7)',
|
|
},
|
|
'alert_emails': {
|
|
'type': 'list',
|
|
'required': True,
|
|
'description': 'Email addresses to notify',
|
|
},
|
|
'min_appointments': {
|
|
'type': 'integer',
|
|
'required': False,
|
|
'default': 10,
|
|
'description': 'Minimum appointments needed before alerting (avoid false positives)',
|
|
},
|
|
}
|
|
|
|
def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Check no-show rate and alert if too high"""
|
|
|
|
from .models import Event
|
|
|
|
lookback_days = self.config.get('lookback_days', 7)
|
|
threshold = self.config.get('threshold_percentage', 20)
|
|
min_appointments = self.config.get('min_appointments', 10)
|
|
|
|
# Calculate date range
|
|
start_date = timezone.now() - timedelta(days=lookback_days)
|
|
|
|
# Get appointment statistics
|
|
total_appointments = Event.objects.filter(
|
|
start_time__gte=start_date,
|
|
start_time__lt=timezone.now(),
|
|
).count()
|
|
|
|
if total_appointments < min_appointments:
|
|
return {
|
|
'success': True,
|
|
'message': f'Not enough data ({total_appointments} appointments, need {min_appointments})',
|
|
'data': {'appointments': total_appointments}
|
|
}
|
|
|
|
canceled_count = Event.objects.filter(
|
|
start_time__gte=start_date,
|
|
start_time__lt=timezone.now(),
|
|
status='CANCELED',
|
|
).count()
|
|
|
|
no_show_rate = (canceled_count / total_appointments) * 100
|
|
|
|
if no_show_rate >= threshold:
|
|
# Send alert
|
|
business = context.get('business')
|
|
self._send_alert(
|
|
business=business,
|
|
no_show_rate=no_show_rate,
|
|
canceled_count=canceled_count,
|
|
total_appointments=total_appointments,
|
|
lookback_days=lookback_days
|
|
)
|
|
|
|
return {
|
|
'success': True,
|
|
'message': f'ALERT: No-show rate is {no_show_rate:.1f}% (threshold: {threshold}%)',
|
|
'data': {
|
|
'no_show_rate': round(no_show_rate, 2),
|
|
'canceled': canceled_count,
|
|
'total': total_appointments,
|
|
'alert_sent': True,
|
|
}
|
|
}
|
|
else:
|
|
return {
|
|
'success': True,
|
|
'message': f'No-show rate is healthy: {no_show_rate:.1f}%',
|
|
'data': {
|
|
'no_show_rate': round(no_show_rate, 2),
|
|
'canceled': canceled_count,
|
|
'total': total_appointments,
|
|
'alert_sent': False,
|
|
}
|
|
}
|
|
|
|
def _send_alert(self, business, no_show_rate, canceled_count, total_appointments, lookback_days):
|
|
"""Send alert email to business owners"""
|
|
|
|
alert_emails = self.config.get('alert_emails', [])
|
|
|
|
subject = f"⚠️ High No-Show Rate Alert - {business.name if business else 'Your Business'}"
|
|
|
|
message = f"""
|
|
Alert: Your no-show rate is unusually high!
|
|
|
|
No-Show Rate: {no_show_rate:.1f}%
|
|
Period: Last {lookback_days} days
|
|
Canceled Appointments: {canceled_count} out of {total_appointments}
|
|
|
|
Recommended Actions:
|
|
1. Review your confirmation process
|
|
2. Send appointment reminders 24 hours before
|
|
3. Implement a cancellation policy
|
|
4. Follow up with customers who no-showed
|
|
|
|
This is an automated alert from your scheduling system.
|
|
"""
|
|
|
|
try:
|
|
send_mail(
|
|
subject=subject,
|
|
message=message,
|
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
recipient_list=alert_emails,
|
|
fail_silently=False,
|
|
)
|
|
logger.info(f"Sent no-show alert to {len(alert_emails)} recipient(s)")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send no-show alert: {e}")
|
|
|
|
|
|
@register_plugin
|
|
class PeakHoursAnalyzerPlugin(BasePlugin):
|
|
"""
|
|
Analyze booking patterns to identify peak hours and make staffing recommendations.
|
|
"""
|
|
|
|
name = "peak_hours_analyzer"
|
|
display_name = "Peak Hours Analyzer"
|
|
description = "Analyze booking patterns and recommend optimal staffing"
|
|
category = "analytics"
|
|
|
|
config_schema = {
|
|
'analysis_days': {
|
|
'type': 'integer',
|
|
'required': False,
|
|
'default': 30,
|
|
'description': 'Analyze data from the last N days (default: 30)',
|
|
},
|
|
'report_emails': {
|
|
'type': 'list',
|
|
'required': True,
|
|
'description': 'Email addresses to receive the analysis report',
|
|
},
|
|
'group_by': {
|
|
'type': 'choice',
|
|
'choices': ['hour', 'day_of_week', 'both'],
|
|
'required': False,
|
|
'default': 'both',
|
|
'description': 'How to group the analysis',
|
|
},
|
|
}
|
|
|
|
def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Analyze booking patterns and send report"""
|
|
|
|
from .models import Event
|
|
from collections import defaultdict
|
|
|
|
analysis_days = self.config.get('analysis_days', 30)
|
|
start_date = timezone.now() - timedelta(days=analysis_days)
|
|
|
|
# Get all appointments in the period
|
|
events = Event.objects.filter(
|
|
start_time__gte=start_date,
|
|
status__in=['COMPLETED', 'PAID', 'SCHEDULED']
|
|
).values_list('start_time', flat=True)
|
|
|
|
# Analyze by hour
|
|
hourly_counts = defaultdict(int)
|
|
weekday_counts = defaultdict(int)
|
|
|
|
for event_time in events:
|
|
hour = event_time.hour
|
|
weekday = event_time.strftime('%A')
|
|
|
|
hourly_counts[hour] += 1
|
|
weekday_counts[weekday] += 1
|
|
|
|
# Find peak hours
|
|
peak_hours = sorted(hourly_counts.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
peak_days = sorted(weekday_counts.items(), key=lambda x: x[1], reverse=True)[:3]
|
|
|
|
# Generate report
|
|
report = self._generate_report(
|
|
peak_hours=peak_hours,
|
|
peak_days=peak_days,
|
|
total_appointments=len(events),
|
|
analysis_days=analysis_days,
|
|
business=context.get('business')
|
|
)
|
|
|
|
# Send report
|
|
self._send_report(report)
|
|
|
|
return {
|
|
'success': True,
|
|
'message': 'Peak hours analysis completed',
|
|
'data': {
|
|
'peak_hours': [f"{h}:00" for h, _ in peak_hours],
|
|
'peak_days': [d for d, _ in peak_days],
|
|
'total_analyzed': len(events),
|
|
}
|
|
}
|
|
|
|
def _generate_report(self, peak_hours, peak_days, total_appointments, analysis_days, business):
|
|
"""Generate human-readable report"""
|
|
|
|
report = f"""
|
|
Peak Hours Analysis Report
|
|
Business: {business.name if business else 'Your Business'}
|
|
Period: Last {analysis_days} days
|
|
Total Appointments: {total_appointments}
|
|
|
|
TOP 5 BUSIEST HOURS:
|
|
"""
|
|
for hour, count in peak_hours:
|
|
percentage = (count / total_appointments * 100) if total_appointments > 0 else 0
|
|
time_label = f"{hour}:00 - {hour+1}:00"
|
|
report += f" {time_label}: {count} appointments ({percentage:.1f}%)\n"
|
|
|
|
report += f"\nTOP 3 BUSIEST DAYS:\n"
|
|
for day, count in peak_days:
|
|
percentage = (count / total_appointments * 100) if total_appointments > 0 else 0
|
|
report += f" {day}: {count} appointments ({percentage:.1f}%)\n"
|
|
|
|
report += f"""
|
|
|
|
RECOMMENDATIONS:
|
|
• Ensure adequate staffing during peak hours
|
|
• Consider offering promotions during slower periods
|
|
• Use this data to optimize your schedule
|
|
• Review this analysis monthly to spot trends
|
|
|
|
This is an automated report from your scheduling system.
|
|
"""
|
|
return report
|
|
|
|
def _send_report(self, report):
|
|
"""Send the analysis report via email"""
|
|
|
|
recipients = self.config.get('report_emails', [])
|
|
|
|
try:
|
|
send_mail(
|
|
subject="📊 Peak Hours Analysis Report",
|
|
message=report,
|
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
recipient_list=recipients,
|
|
fail_silently=False,
|
|
)
|
|
logger.info(f"Sent peak hours report to {len(recipients)} recipient(s)")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send peak hours report: {e}")
|