feat: Add event automation system for plugins attached to appointments

- Add GlobalEventPlugin model for auto-attaching plugins to all events
- Create signals for auto-attachment on new events and rescheduling
- Add API endpoints for global event plugins (CRUD, toggle, reapply)
- Update CreateTaskModal with "Scheduled Task" vs "Event Automation" choice
- Add option to apply to all events or future events only
- Display event automations in Tasks page alongside scheduled tasks
- Add EditEventAutomationModal for editing trigger and timing
- Handle event reschedule - update Celery task timing on time/duration changes
- Add Marketplace to Plugins menu in sidebar

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-29 12:17:28 -05:00
parent 9b106bf129
commit 0c7d76e264
18 changed files with 3061 additions and 175 deletions

View File

@@ -0,0 +1,719 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from '../api/client';
import { X, Calendar, Clock, RotateCw, Zap, CalendarDays, ChevronDown, ChevronUp } from 'lucide-react';
import toast from 'react-hot-toast';
interface PluginInstallation {
id: string;
template: number;
template_name: string;
template_slug: string;
template_description: string;
category: string;
version: string;
author_name: string;
logo_url?: string;
template_variables: Record<string, any>;
scheduled_task?: number;
scheduled_task_name?: string;
installed_at: string;
config_values: Record<string, any>;
has_update: boolean;
}
interface CreateTaskModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
// Schedule presets for visual selection
interface SchedulePreset {
id: string;
label: string;
description: string;
type: 'INTERVAL' | 'CRON';
interval_minutes?: number;
cron_expression?: string;
}
const SCHEDULE_PRESETS: SchedulePreset[] = [
// Interval-based
{ id: 'every_15min', label: 'Every 15 minutes', description: 'Runs 4 times per hour', type: 'INTERVAL', interval_minutes: 15 },
{ id: 'every_30min', label: 'Every 30 minutes', description: 'Runs twice per hour', type: 'INTERVAL', interval_minutes: 30 },
{ id: 'every_hour', label: 'Every hour', description: 'Runs 24 times per day', type: 'INTERVAL', interval_minutes: 60 },
{ id: 'every_2hours', label: 'Every 2 hours', description: 'Runs 12 times per day', type: 'INTERVAL', interval_minutes: 120 },
{ id: 'every_4hours', label: 'Every 4 hours', description: 'Runs 6 times per day', type: 'INTERVAL', interval_minutes: 240 },
{ id: 'every_6hours', label: 'Every 6 hours', description: 'Runs 4 times per day', type: 'INTERVAL', interval_minutes: 360 },
{ id: 'every_12hours', label: 'Twice daily', description: 'Runs at midnight and noon', type: 'INTERVAL', interval_minutes: 720 },
// Cron-based (specific times)
{ id: 'daily_midnight', label: 'Daily at midnight', description: 'Runs once per day at 12:00 AM', type: 'CRON', cron_expression: '0 0 * * *' },
{ id: 'daily_9am', label: 'Daily at 9 AM', description: 'Runs once per day at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * *' },
{ id: 'daily_6pm', label: 'Daily at 6 PM', description: 'Runs once per day at 6:00 PM', type: 'CRON', cron_expression: '0 18 * * *' },
{ id: 'weekdays_9am', label: 'Weekdays at 9 AM', description: 'Mon-Fri at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * 1-5' },
{ id: 'weekdays_6pm', label: 'Weekdays at 6 PM', description: 'Mon-Fri at 6:00 PM', type: 'CRON', cron_expression: '0 18 * * 1-5' },
{ id: 'weekly_sunday', label: 'Weekly on Sunday', description: 'Every Sunday at midnight', type: 'CRON', cron_expression: '0 0 * * 0' },
{ id: 'weekly_monday', label: 'Weekly on Monday', description: 'Every Monday at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * 1' },
{ id: 'monthly_1st', label: 'Monthly on the 1st', description: 'First day of each month', type: 'CRON', cron_expression: '0 0 1 * *' },
];
// Event trigger options (same as EventAutomations component)
interface TriggerOption {
value: string;
label: string;
}
interface OffsetPreset {
value: number;
label: string;
}
const TRIGGER_OPTIONS: TriggerOption[] = [
{ value: 'before_start', label: 'Before Start' },
{ value: 'at_start', label: 'At Start' },
{ value: 'after_start', label: 'After Start' },
{ value: 'after_end', label: 'After End' },
{ value: 'on_complete', label: 'When Completed' },
{ value: 'on_cancel', label: 'When Canceled' },
];
const OFFSET_PRESETS: OffsetPreset[] = [
{ value: 0, label: 'Immediately' },
{ value: 5, label: '5 min' },
{ value: 10, label: '10 min' },
{ value: 15, label: '15 min' },
{ value: 30, label: '30 min' },
{ value: 60, label: '1 hour' },
];
// Task type: scheduled or event-based
type TaskType = 'scheduled' | 'event';
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSuccess }) => {
const queryClient = useQueryClient();
const [step, setStep] = useState(1);
const [selectedPlugin, setSelectedPlugin] = useState<PluginInstallation | null>(null);
const [taskName, setTaskName] = useState('');
const [description, setDescription] = useState('');
// Task type selection
const [taskType, setTaskType] = useState<TaskType>('scheduled');
// Schedule selection (for scheduled tasks)
const [selectedPreset, setSelectedPreset] = useState<string>('every_hour');
const [scheduleMode, setScheduleMode] = useState<'preset' | 'onetime' | 'advanced'>('preset');
// One-time schedule
const [runAtDate, setRunAtDate] = useState('');
const [runAtTime, setRunAtTime] = useState('');
// Advanced (custom cron)
const [customCron, setCustomCron] = useState('0 0 * * *');
// Event automation settings (for event tasks)
const [selectedTrigger, setSelectedTrigger] = useState<string>('at_start');
const [selectedOffset, setSelectedOffset] = useState<number>(0);
const [applyToExisting, setApplyToExisting] = useState<boolean>(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
// Fetch available plugins
const { data: plugins = [], isLoading: pluginsLoading } = useQuery<PluginInstallation[]>({
queryKey: ['plugin-installations'],
queryFn: async () => {
const { data } = await axios.get('/api/plugin-installations/');
// Filter out plugins that already have scheduled tasks
return data.filter((p: PluginInstallation) => !p.scheduled_task);
},
enabled: isOpen,
});
const handleClose = () => {
setStep(1);
setSelectedPlugin(null);
setTaskName('');
setDescription('');
setError('');
setTaskType('scheduled');
setScheduleMode('preset');
setSelectedPreset('every_hour');
setSelectedTrigger('at_start');
setSelectedOffset(0);
setApplyToExisting(true);
setRunAtDate('');
setRunAtTime('');
setCustomCron('0 0 * * *');
onClose();
};
const handlePluginSelect = (plugin: PluginInstallation) => {
setSelectedPlugin(plugin);
setTaskName(`${plugin.template_name} - Scheduled Task`);
setStep(2);
};
const getScheduleDescription = () => {
if (scheduleMode === 'onetime') {
if (runAtDate && runAtTime) {
return `Once on ${new Date(`${runAtDate}T${runAtTime}`).toLocaleString()}`;
}
return 'Select date and time';
}
if (scheduleMode === 'advanced') {
return `Custom: ${customCron}`;
}
const preset = SCHEDULE_PRESETS.find(p => p.id === selectedPreset);
return preset?.description || 'Select a schedule';
};
const getEventTimingDescription = () => {
const trigger = TRIGGER_OPTIONS.find(t => t.value === selectedTrigger);
if (!trigger) return 'Select timing';
if (selectedTrigger === 'on_complete') return 'When event is completed';
if (selectedTrigger === 'on_cancel') return 'When event is canceled';
if (selectedOffset === 0) {
if (selectedTrigger === 'before_start') return 'At event start';
if (selectedTrigger === 'at_start') return 'At event start';
if (selectedTrigger === 'after_start') return 'At event start';
if (selectedTrigger === 'after_end') return 'At event end';
}
const offsetLabel = OFFSET_PRESETS.find(o => o.value === selectedOffset)?.label || `${selectedOffset} min`;
if (selectedTrigger === 'before_start') return `${offsetLabel} before event starts`;
if (selectedTrigger === 'at_start' || selectedTrigger === 'after_start') return `${offsetLabel} after event starts`;
if (selectedTrigger === 'after_end') return `${offsetLabel} after event ends`;
return trigger.label;
};
const showOffset = !['on_complete', 'on_cancel'].includes(selectedTrigger);
const handleSubmit = async () => {
if (!selectedPlugin) return;
setIsSubmitting(true);
setError('');
try {
if (taskType === 'event') {
// Create global event plugin (applies to all events)
const payload = {
plugin_installation: selectedPlugin.id,
trigger: selectedTrigger,
offset_minutes: selectedOffset,
is_active: true,
apply_to_existing: applyToExisting,
};
await axios.post('/api/global-event-plugins/', payload);
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
toast.success(applyToExisting ? 'Plugin attached to all events' : 'Plugin will apply to future events');
} else {
// Create scheduled task
let payload: any = {
name: taskName,
description,
plugin_name: selectedPlugin.template_slug,
status: 'ACTIVE',
plugin_config: selectedPlugin.config_values || {},
};
if (scheduleMode === 'onetime') {
payload.schedule_type = 'ONE_TIME';
payload.run_at = `${runAtDate}T${runAtTime}:00`;
} else if (scheduleMode === 'advanced') {
payload.schedule_type = 'CRON';
payload.cron_expression = customCron;
} else {
const preset = SCHEDULE_PRESETS.find(p => p.id === selectedPreset);
if (preset) {
payload.schedule_type = preset.type;
if (preset.type === 'INTERVAL') {
payload.interval_minutes = preset.interval_minutes;
} else {
payload.cron_expression = preset.cron_expression;
}
}
}
await axios.post('/api/scheduled-tasks/', payload);
toast.success('Scheduled task created');
}
onSuccess();
handleClose();
} catch (err: any) {
setError(err.response?.data?.detail || err.response?.data?.error || 'Failed to create task');
} finally {
setIsSubmitting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Schedule a Task
</h2>
<button
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Steps indicator */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-center gap-4">
<div className={`flex items-center gap-2 ${step >= 1 ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${step >= 1 ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'}`}>
1
</div>
<span className="font-medium">Select Plugin</span>
</div>
<div className="w-16 h-0.5 bg-gray-200 dark:bg-gray-700" />
<div className={`flex items-center gap-2 ${step >= 2 ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${step >= 2 ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'}`}>
2
</div>
<span className="font-medium">Configure</span>
</div>
</div>
</div>
{/* Content */}
<div className="p-6">
{/* Step 1: Select Plugin */}
{step === 1 && (
<div>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Select a plugin to schedule for automatic execution.
</p>
{pluginsLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : plugins.length === 0 ? (
<div className="text-center py-12 bg-gray-50 dark:bg-gray-900 rounded-lg">
<Zap className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 dark:text-gray-400 mb-2">No available plugins</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
Install plugins from the Marketplace first, or all your plugins are already scheduled.
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3 max-h-96 overflow-y-auto">
{plugins.map((plugin) => (
<button
key={plugin.id}
onClick={() => handlePluginSelect(plugin)}
className="text-left p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-500 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
>
<div className="flex items-start gap-3">
{plugin.logo_url ? (
<img src={plugin.logo_url} alt="" className="w-10 h-10 rounded" />
) : (
<div className="w-10 h-10 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
)}
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
{plugin.template_name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{plugin.template_description}
</p>
</div>
</div>
</button>
))}
</div>
)}
</div>
)}
{/* Step 2: Configure Schedule */}
{step === 2 && selectedPlugin && (
<div className="space-y-6">
{/* Selected Plugin */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<div>
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium">
Selected Plugin
</p>
<p className="text-blue-900 dark:text-blue-100">
{selectedPlugin.template_name}
</p>
</div>
</div>
</div>
{/* Task Type Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
How should this plugin run?
</label>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => setTaskType('scheduled')}
className={`p-4 rounded-lg border-2 text-left transition-all ${
taskType === 'scheduled'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-center gap-2 mb-2">
<Clock className={`w-5 h-5 ${taskType === 'scheduled' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`} />
<span className={`font-semibold ${taskType === 'scheduled' ? 'text-blue-700 dark:text-blue-300' : 'text-gray-900 dark:text-white'}`}>
Scheduled Task
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Run on a fixed schedule (hourly, daily, weekly, etc.)
</p>
</button>
<button
onClick={() => setTaskType('event')}
className={`p-4 rounded-lg border-2 text-left transition-all ${
taskType === 'event'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-center gap-2 mb-2">
<CalendarDays className={`w-5 h-5 ${taskType === 'event' ? 'text-purple-600 dark:text-purple-400' : 'text-gray-400'}`} />
<span className={`font-semibold ${taskType === 'event' ? 'text-purple-700 dark:text-purple-300' : 'text-gray-900 dark:text-white'}`}>
Event Automation
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Run for every appointment on your calendar
</p>
</button>
</div>
</div>
{/* Scheduled Task Options */}
{taskType === 'scheduled' && (
<>
{/* Task Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Task Name
</label>
<input
type="text"
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
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"
placeholder="e.g., Daily Customer Report"
/>
</div>
{/* Schedule Mode Tabs */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Schedule
</label>
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden mb-4">
<button
onClick={() => setScheduleMode('preset')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
scheduleMode === 'preset'
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<RotateCw className="w-4 h-4 inline mr-2" />
Recurring
</button>
<button
onClick={() => setScheduleMode('onetime')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
scheduleMode === 'onetime'
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Calendar className="w-4 h-4 inline mr-2" />
One-Time
</button>
<button
onClick={() => setScheduleMode('advanced')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
scheduleMode === 'advanced'
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Clock className="w-4 h-4 inline mr-2" />
Advanced
</button>
</div>
{/* Preset Selection */}
{scheduleMode === 'preset' && (
<div className="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto">
{SCHEDULE_PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => setSelectedPreset(preset.id)}
className={`text-left p-3 rounded-lg border transition-colors ${
selectedPreset === preset.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<p className={`text-sm font-medium ${selectedPreset === preset.id ? 'text-blue-700 dark:text-blue-300' : 'text-gray-900 dark:text-white'}`}>
{preset.label}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{preset.description}
</p>
</button>
))}
</div>
)}
{/* One-Time Selection */}
{scheduleMode === 'onetime' && (
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Date
</label>
<input
type="date"
value={runAtDate}
onChange={(e) => setRunAtDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Time
</label>
<input
type="time"
value={runAtTime}
onChange={(e) => setRunAtTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
</div>
</div>
)}
{/* Advanced Cron */}
{scheduleMode === 'advanced' && (
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-3">
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Cron Expression
</label>
<input
type="text"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm"
placeholder="0 0 * * *"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Format: minute hour day month weekday (e.g., "0 9 * * 1-5" = weekdays at 9 AM)
</p>
</div>
</div>
)}
</div>
{/* Schedule Preview */}
<div className="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-800 dark:text-green-200">
<strong>Schedule:</strong> {getScheduleDescription()}
</span>
</div>
</div>
</>
)}
{/* Event Automation Options */}
{taskType === 'event' && (
<>
{/* Apply to which events */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Apply to which events?
</label>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => setApplyToExisting(true)}
className={`p-3 rounded-lg border-2 text-left transition-all ${
applyToExisting
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<span className={`font-medium text-sm ${applyToExisting ? 'text-purple-700 dark:text-purple-300' : 'text-gray-900 dark:text-white'}`}>
All Events
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Existing + future appointments
</p>
</button>
<button
onClick={() => setApplyToExisting(false)}
className={`p-3 rounded-lg border-2 text-left transition-all ${
!applyToExisting
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<span className={`font-medium text-sm ${!applyToExisting ? 'text-purple-700 dark:text-purple-300' : 'text-gray-900 dark:text-white'}`}>
Future Only
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Only new appointments
</p>
</button>
</div>
</div>
{/* Info about timing updates */}
<div className="p-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<p className="text-xs text-gray-600 dark:text-gray-400">
If an event is rescheduled, the plugin timing will automatically update.
</p>
</div>
{/* When to run */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
When should it run?
</label>
<div className="grid grid-cols-2 gap-2">
{TRIGGER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
setSelectedTrigger(opt.value);
if (['on_complete', 'on_cancel'].includes(opt.value)) {
setSelectedOffset(0);
}
}}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
selectedTrigger === opt.value
? 'bg-purple-600 text-white border-purple-600'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-purple-400'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Offset selection (only for time-based triggers) */}
{showOffset && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
How long {selectedTrigger === 'before_start' ? 'before' : 'after'}?
</label>
<div className="flex flex-wrap gap-2">
{OFFSET_PRESETS.map((preset) => (
<button
key={preset.value}
type="button"
onClick={() => setSelectedOffset(preset.value)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
selectedOffset === preset.value
? 'bg-purple-600 text-white border-purple-600'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-purple-400'
}`}
>
{preset.label}
</button>
))}
</div>
</div>
)}
{/* Event Timing Preview */}
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 border border-purple-300 dark:border-purple-700 rounded-lg">
<div className="flex items-center gap-2">
<CalendarDays className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm text-purple-800 dark:text-purple-200">
<strong>Runs:</strong> {getEventTimingDescription()}
</span>
</div>
</div>
</>
)}
{/* Error */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
{step === 2 && (
<button
onClick={() => setStep(1)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Back
</button>
)}
<div className="flex-1" />
<button
onClick={handleClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Cancel
</button>
{step === 2 && (
<button
onClick={handleSubmit}
disabled={
isSubmitting ||
(taskType === 'scheduled' && !taskName) ||
(taskType === 'scheduled' && scheduleMode === 'onetime' && (!runAtDate || !runAtTime))
}
className={`px-4 py-2 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
taskType === 'event' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isSubmitting
? 'Creating...'
: taskType === 'event'
? 'Attach to All Events'
: 'Create Task'}
</button>
)}
</div>
</div>
</div>
);
};
export default CreateTaskModal;

View File

@@ -0,0 +1,382 @@
import React, { useState, useEffect } from 'react';
import axios from '../api/client';
import { X, Calendar, Clock, RotateCw, Zap } from 'lucide-react';
interface ScheduledTask {
id: string;
name: string;
description: string;
plugin_name: string;
plugin_display_name: string;
schedule_type: 'ONE_TIME' | 'INTERVAL' | 'CRON';
cron_expression?: string;
interval_minutes?: number;
run_at?: string;
next_run_at?: string;
last_run_at?: string;
status: 'ACTIVE' | 'PAUSED' | 'DISABLED';
last_run_status?: string;
plugin_config: Record<string, any>;
created_at: string;
updated_at: string;
}
interface EditTaskModalProps {
task: ScheduledTask | null;
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
// Schedule presets for visual selection
interface SchedulePreset {
id: string;
label: string;
description: string;
type: 'INTERVAL' | 'CRON';
interval_minutes?: number;
cron_expression?: string;
}
const SCHEDULE_PRESETS: SchedulePreset[] = [
{ id: 'every_15min', label: 'Every 15 minutes', description: 'Runs 4 times per hour', type: 'INTERVAL', interval_minutes: 15 },
{ id: 'every_30min', label: 'Every 30 minutes', description: 'Runs twice per hour', type: 'INTERVAL', interval_minutes: 30 },
{ id: 'every_hour', label: 'Every hour', description: 'Runs 24 times per day', type: 'INTERVAL', interval_minutes: 60 },
{ id: 'every_2hours', label: 'Every 2 hours', description: 'Runs 12 times per day', type: 'INTERVAL', interval_minutes: 120 },
{ id: 'every_4hours', label: 'Every 4 hours', description: 'Runs 6 times per day', type: 'INTERVAL', interval_minutes: 240 },
{ id: 'every_6hours', label: 'Every 6 hours', description: 'Runs 4 times per day', type: 'INTERVAL', interval_minutes: 360 },
{ id: 'every_12hours', label: 'Twice daily', description: 'Runs at midnight and noon', type: 'INTERVAL', interval_minutes: 720 },
{ id: 'daily_midnight', label: 'Daily at midnight', description: 'Runs once per day at 12:00 AM', type: 'CRON', cron_expression: '0 0 * * *' },
{ id: 'daily_9am', label: 'Daily at 9 AM', description: 'Runs once per day at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * *' },
{ id: 'daily_6pm', label: 'Daily at 6 PM', description: 'Runs once per day at 6:00 PM', type: 'CRON', cron_expression: '0 18 * * *' },
{ id: 'weekdays_9am', label: 'Weekdays at 9 AM', description: 'Mon-Fri at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * 1-5' },
{ id: 'weekdays_6pm', label: 'Weekdays at 6 PM', description: 'Mon-Fri at 6:00 PM', type: 'CRON', cron_expression: '0 18 * * 1-5' },
{ id: 'weekly_sunday', label: 'Weekly on Sunday', description: 'Every Sunday at midnight', type: 'CRON', cron_expression: '0 0 * * 0' },
{ id: 'weekly_monday', label: 'Weekly on Monday', description: 'Every Monday at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * 1' },
{ id: 'monthly_1st', label: 'Monthly on the 1st', description: 'First day of each month', type: 'CRON', cron_expression: '0 0 1 * *' },
];
const EditTaskModal: React.FC<EditTaskModalProps> = ({ task, isOpen, onClose, onSuccess }) => {
const [taskName, setTaskName] = useState('');
const [description, setDescription] = useState('');
const [selectedPreset, setSelectedPreset] = useState<string>('');
const [scheduleMode, setScheduleMode] = useState<'preset' | 'onetime' | 'advanced'>('preset');
const [runAtDate, setRunAtDate] = useState('');
const [runAtTime, setRunAtTime] = useState('');
const [customCron, setCustomCron] = useState('0 0 * * *');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
// Load task data when modal opens
useEffect(() => {
if (task && isOpen) {
setTaskName(task.name);
setDescription(task.description || '');
// Determine schedule mode and preset
if (task.schedule_type === 'ONE_TIME') {
setScheduleMode('onetime');
if (task.run_at) {
const date = new Date(task.run_at);
setRunAtDate(date.toISOString().split('T')[0]);
setRunAtTime(date.toTimeString().slice(0, 5));
}
} else if (task.schedule_type === 'INTERVAL') {
// Try to match to a preset
const matchingPreset = SCHEDULE_PRESETS.find(
p => p.type === 'INTERVAL' && p.interval_minutes === task.interval_minutes
);
if (matchingPreset) {
setScheduleMode('preset');
setSelectedPreset(matchingPreset.id);
} else {
setScheduleMode('advanced');
// Convert interval to rough cron (not perfect, but gives user a starting point)
setCustomCron(`*/${task.interval_minutes} * * * *`);
}
} else if (task.schedule_type === 'CRON' && task.cron_expression) {
// Try to match to a preset
const matchingPreset = SCHEDULE_PRESETS.find(
p => p.type === 'CRON' && p.cron_expression === task.cron_expression
);
if (matchingPreset) {
setScheduleMode('preset');
setSelectedPreset(matchingPreset.id);
} else {
setScheduleMode('advanced');
setCustomCron(task.cron_expression);
}
}
}
}, [task, isOpen]);
const handleClose = () => {
setError('');
onClose();
};
const getScheduleDescription = () => {
if (scheduleMode === 'onetime') {
if (runAtDate && runAtTime) {
return `Once on ${new Date(`${runAtDate}T${runAtTime}`).toLocaleString()}`;
}
return 'Select date and time';
}
if (scheduleMode === 'advanced') {
return `Custom: ${customCron}`;
}
const preset = SCHEDULE_PRESETS.find(p => p.id === selectedPreset);
return preset?.description || 'Select a schedule';
};
const handleSubmit = async () => {
if (!task) return;
setIsSubmitting(true);
setError('');
try {
let payload: any = {
name: taskName,
description,
};
if (scheduleMode === 'onetime') {
payload.schedule_type = 'ONE_TIME';
payload.run_at = `${runAtDate}T${runAtTime}:00`;
payload.interval_minutes = null;
payload.cron_expression = null;
} else if (scheduleMode === 'advanced') {
payload.schedule_type = 'CRON';
payload.cron_expression = customCron;
payload.interval_minutes = null;
payload.run_at = null;
} else {
const preset = SCHEDULE_PRESETS.find(p => p.id === selectedPreset);
if (preset) {
payload.schedule_type = preset.type;
if (preset.type === 'INTERVAL') {
payload.interval_minutes = preset.interval_minutes;
payload.cron_expression = null;
} else {
payload.cron_expression = preset.cron_expression;
payload.interval_minutes = null;
}
payload.run_at = null;
}
}
await axios.patch(`/api/scheduled-tasks/${task.id}/`, payload);
onSuccess();
handleClose();
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to update task');
} finally {
setIsSubmitting(false);
}
};
if (!isOpen || !task) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Edit Task
</h2>
<button
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Plugin Info (read-only) */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<div>
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium">
Plugin
</p>
<p className="text-blue-900 dark:text-blue-100">
{task.plugin_display_name}
</p>
</div>
</div>
</div>
{/* Task Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Task Name
</label>
<input
type="text"
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
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"
/>
</div>
{/* Schedule Mode Tabs */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Schedule
</label>
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden mb-4">
<button
onClick={() => setScheduleMode('preset')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
scheduleMode === 'preset'
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<RotateCw className="w-4 h-4 inline mr-2" />
Recurring
</button>
<button
onClick={() => setScheduleMode('onetime')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
scheduleMode === 'onetime'
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Calendar className="w-4 h-4 inline mr-2" />
One-Time
</button>
<button
onClick={() => setScheduleMode('advanced')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
scheduleMode === 'advanced'
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Clock className="w-4 h-4 inline mr-2" />
Advanced
</button>
</div>
{/* Preset Selection */}
{scheduleMode === 'preset' && (
<div className="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto">
{SCHEDULE_PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => setSelectedPreset(preset.id)}
className={`text-left p-3 rounded-lg border transition-colors ${
selectedPreset === preset.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<p className={`text-sm font-medium ${selectedPreset === preset.id ? 'text-blue-700 dark:text-blue-300' : 'text-gray-900 dark:text-white'}`}>
{preset.label}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{preset.description}
</p>
</button>
))}
</div>
)}
{/* One-Time Selection */}
{scheduleMode === 'onetime' && (
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Date
</label>
<input
type="date"
value={runAtDate}
onChange={(e) => setRunAtDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Time
</label>
<input
type="time"
value={runAtTime}
onChange={(e) => setRunAtTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
</div>
</div>
)}
{/* Advanced Cron */}
{scheduleMode === 'advanced' && (
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-3">
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Cron Expression
</label>
<input
type="text"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm"
placeholder="0 0 * * *"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Format: minute hour day month weekday (e.g., "0 9 * * 1-5" = weekdays at 9 AM)
</p>
</div>
</div>
)}
</div>
{/* Schedule Preview */}
<div className="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-800 dark:text-green-200">
<strong>Schedule:</strong> {getScheduleDescription()}
</span>
</div>
</div>
{/* Error */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isSubmitting || !taskName || (scheduleMode === 'onetime' && (!runAtDate || !runAtTime)) || (scheduleMode === 'preset' && !selectedPreset)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
);
};
export default EditTaskModal;

View File

@@ -0,0 +1,361 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from '../api/client';
import { Zap, Plus, Trash2, Clock, CheckCircle2, XCircle, ChevronDown, Power } from 'lucide-react';
import toast from 'react-hot-toast';
interface PluginInstallation {
id: string;
template_name: string;
template_description: string;
category: string;
logo_url?: string;
}
interface EventPlugin {
id: string;
event: number;
plugin_installation: number;
plugin_name: string;
plugin_description: string;
plugin_category: string;
plugin_logo_url?: string;
trigger: string;
trigger_display: string;
offset_minutes: number;
timing_description: string;
is_active: boolean;
execution_order: number;
}
interface TriggerOption {
value: string;
label: string;
}
interface OffsetPreset {
value: number;
label: string;
}
interface EventAutomationsProps {
eventId: string | number;
compact?: boolean;
}
const TRIGGER_OPTIONS: TriggerOption[] = [
{ value: 'before_start', label: 'Before Start' },
{ value: 'at_start', label: 'At Start' },
{ value: 'after_start', label: 'After Start' },
{ value: 'after_end', label: 'After End' },
{ value: 'on_complete', label: 'When Completed' },
{ value: 'on_cancel', label: 'When Canceled' },
];
const OFFSET_PRESETS: OffsetPreset[] = [
{ value: 0, label: 'Immediately' },
{ value: 5, label: '5 min' },
{ value: 10, label: '10 min' },
{ value: 15, label: '15 min' },
{ value: 30, label: '30 min' },
{ value: 60, label: '1 hour' },
];
const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact = false }) => {
const queryClient = useQueryClient();
const [showAddForm, setShowAddForm] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<string>('');
const [selectedTrigger, setSelectedTrigger] = useState<string>('at_start');
const [selectedOffset, setSelectedOffset] = useState<number>(0);
// Fetch installed plugins
const { data: plugins = [] } = useQuery<PluginInstallation[]>({
queryKey: ['plugin-installations'],
queryFn: async () => {
const { data } = await axios.get('/api/plugin-installations/');
return data;
},
});
// Fetch event plugins
const { data: eventPlugins = [], isLoading } = useQuery<EventPlugin[]>({
queryKey: ['event-plugins', eventId],
queryFn: async () => {
const { data } = await axios.get(`/api/event-plugins/?event_id=${eventId}`);
return data;
},
enabled: !!eventId,
});
// Add plugin mutation
const addMutation = useMutation({
mutationFn: async (data: { plugin_installation: string; trigger: string; offset_minutes: number }) => {
return axios.post('/api/event-plugins/', {
event: eventId,
...data,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-plugins', eventId] });
setShowAddForm(false);
setSelectedPlugin('');
setSelectedTrigger('at_start');
setSelectedOffset(0);
toast.success('Automation added');
},
onError: () => {
toast.error('Failed to add automation');
},
});
// Toggle mutation
const toggleMutation = useMutation({
mutationFn: async (pluginId: string) => {
return axios.post(`/api/event-plugins/${pluginId}/toggle/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-plugins', eventId] });
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async (pluginId: string) => {
return axios.delete(`/api/event-plugins/${pluginId}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-plugins', eventId] });
toast.success('Automation removed');
},
});
const handleAdd = () => {
if (!selectedPlugin) return;
addMutation.mutate({
plugin_installation: selectedPlugin,
trigger: selectedTrigger,
offset_minutes: selectedOffset,
});
};
const showOffset = !['on_complete', 'on_cancel'].includes(selectedTrigger);
if (isLoading) {
return (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
Loading automations...
</div>
);
}
return (
<div className={`${compact ? 'space-y-3' : 'space-y-4'}`}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm font-medium text-gray-900 dark:text-white">
Automations
</span>
{eventPlugins.length > 0 && (
<span className="px-1.5 py-0.5 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded">
{eventPlugins.length}
</span>
)}
</div>
{!showAddForm && plugins.length > 0 && (
<button
onClick={() => setShowAddForm(true)}
className="flex items-center gap-1 px-2 py-1 text-xs text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded transition-colors"
>
<Plus className="w-3 h-3" />
Add
</button>
)}
</div>
{/* Existing automations */}
{eventPlugins.length > 0 && (
<div className="space-y-2">
{eventPlugins.map((ep) => (
<div
key={ep.id}
className={`flex items-center justify-between p-2 rounded-lg border ${
ep.is_active
? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700'
: 'bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700 opacity-60'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<div className={`p-1.5 rounded ${ep.is_active ? 'bg-purple-100 dark:bg-purple-900/30' : 'bg-gray-100 dark:bg-gray-800'}`}>
<Zap className={`w-3 h-3 ${ep.is_active ? 'text-purple-600 dark:text-purple-400' : 'text-gray-400'}`} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{ep.plugin_name}
</p>
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<Clock className="w-3 h-3" />
<span>{ep.timing_description}</span>
</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => toggleMutation.mutate(ep.id)}
className={`p-1.5 rounded transition-colors ${
ep.is_active
? 'text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20'
: 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={ep.is_active ? 'Disable' : 'Enable'}
>
<Power className="w-3.5 h-3.5" />
</button>
<button
onClick={() => deleteMutation.mutate(ep.id)}
className="p-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title="Remove"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
{/* Add automation form */}
{showAddForm && (
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800 space-y-3">
{/* Plugin selector */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Plugin
</label>
<select
value={selectedPlugin}
onChange={(e) => setSelectedPlugin(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="">Select a plugin...</option>
{plugins.map((p) => (
<option key={p.id} value={p.id}>
{p.template_name}
</option>
))}
</select>
</div>
{/* Timing selector - visual */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
When to run
</label>
<div className="grid grid-cols-2 gap-1.5">
{TRIGGER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
setSelectedTrigger(opt.value);
if (['on_complete', 'on_cancel'].includes(opt.value)) {
setSelectedOffset(0);
}
}}
className={`px-2 py-1.5 text-xs rounded border transition-colors ${
selectedTrigger === opt.value
? 'bg-purple-600 text-white border-purple-600'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-purple-400'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Offset selector - only for time-based triggers */}
{showOffset && (
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Offset
</label>
<div className="flex flex-wrap gap-1.5">
{OFFSET_PRESETS.map((preset) => (
<button
key={preset.value}
type="button"
onClick={() => setSelectedOffset(preset.value)}
className={`px-2 py-1 text-xs rounded border transition-colors ${
selectedOffset === preset.value
? 'bg-purple-600 text-white border-purple-600'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-purple-400'
}`}
>
{preset.label}
</button>
))}
</div>
</div>
)}
{/* Preview */}
{selectedPlugin && (
<div className="text-xs text-purple-800 dark:text-purple-200 bg-purple-100 dark:bg-purple-900/40 px-2 py-1.5 rounded">
Will run: <strong>
{selectedTrigger === 'on_complete' ? 'When completed' :
selectedTrigger === 'on_cancel' ? 'When canceled' :
selectedTrigger === 'before_start' && selectedOffset > 0 ? `${selectedOffset} min before start` :
selectedTrigger === 'after_end' && selectedOffset > 0 ? `${selectedOffset} min after end` :
selectedTrigger === 'after_start' && selectedOffset > 0 ? `${selectedOffset} min after start` :
selectedTrigger === 'at_start' && selectedOffset > 0 ? `${selectedOffset} min after start` :
selectedTrigger === 'before_start' ? 'At start' :
selectedTrigger === 'after_end' ? 'At end' :
'At start'}
</strong>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
<button
onClick={() => setShowAddForm(false)}
className="px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
>
Cancel
</button>
<button
onClick={handleAdd}
disabled={!selectedPlugin || addMutation.isPending}
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{addMutation.isPending ? 'Adding...' : 'Add Automation'}
</button>
</div>
</div>
)}
{/* Empty state */}
{eventPlugins.length === 0 && !showAddForm && (
<div className="text-center py-3 text-gray-500 dark:text-gray-400">
{plugins.length > 0 ? (
<button
onClick={() => setShowAddForm(true)}
className="text-xs text-purple-600 dark:text-purple-400 hover:underline"
>
+ Add your first automation
</button>
) : (
<p className="text-xs">
Install plugins from the Marketplace to use automations
</p>
)}
</div>
)}
</div>
);
};
export default EventAutomations;

View File

@@ -20,7 +20,9 @@ import {
LifeBuoy,
Zap,
Plug,
Package
Package,
Clock,
Store
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
@@ -39,7 +41,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
const { role } = user;
const logoutMutation = useLogout();
const [isHelpOpen, setIsHelpOpen] = useState(location.pathname.startsWith('/help') || location.pathname === '/support');
const [isPluginsOpen, setIsPluginsOpen] = useState(location.pathname.startsWith('/plugins'));
const [isPluginsOpen, setIsPluginsOpen] = useState(location.pathname.startsWith('/plugins') || location.pathname === '/plugins/marketplace');
const getNavClass = (path: string, exact: boolean = false, disabled: boolean = false) => {
const isActive = exact
@@ -134,6 +136,11 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{!isCollapsed && <span>{t('nav.scheduler')}</span>}
</Link>
<Link to="/tasks" className={getNavClass('/tasks')} title={t('nav.tasks', 'Tasks')}>
<Clock size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.tasks', 'Tasks')}</span>}
</Link>
{canViewManagementPages && (
<>
<Link to="/customers" className={getNavClass('/customers')} title={t('nav.customers')}>
@@ -203,6 +210,14 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
</button>
{isPluginsOpen && !isCollapsed && (
<div className="ml-4 mt-1 space-y-1 border-l border-white/20 pl-4">
<Link
to="/plugins/marketplace"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/plugins/marketplace' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
title={t('nav.marketplace', 'Marketplace')}
>
<Store size={16} className="shrink-0" />
<span>{t('nav.marketplace', 'Marketplace')}</span>
</Link>
<Link
to="/plugins/my-plugins"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/plugins/my-plugins' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}

View File

@@ -74,6 +74,7 @@
"nav": {
"dashboard": "Dashboard",
"scheduler": "Scheduler",
"tasks": "Tasks",
"customers": "Customers",
"resources": "Resources",
"services": "Services",

View File

@@ -18,7 +18,9 @@ import {
Bot,
Package as PackageIcon,
X,
AlertTriangle
AlertTriangle,
Clock,
Settings
} from 'lucide-react';
import api from '../api/client';
import { PluginInstallation, PluginCategory } from '../types';
@@ -377,6 +379,27 @@ const MyPlugins: React.FC = () => {
{/* Actions */}
<div className="flex items-center gap-2 ml-4">
{/* Schedule button - only if not already scheduled */}
{!plugin.scheduledTaskId && (
<button
onClick={(e) => {
e.stopPropagation();
navigate('/tasks');
}}
className="flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
title={t('plugins.schedule', 'Schedule')}
>
<Clock className="h-4 w-4" />
{t('plugins.schedule', 'Schedule')}
</button>
)}
{/* Already scheduled indicator */}
{plugin.scheduledTaskId && (
<span className="flex items-center gap-1.5 px-3 py-2 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg text-sm font-medium">
<Clock className="h-4 w-4" />
{t('plugins.scheduled', 'Scheduled')}
</span>
)}
{plugin.hasUpdate && (
<button
onClick={(e) => {

View File

@@ -10,6 +10,7 @@ import { useResources } from '../hooks/useResources';
import { useServices } from '../hooks/useServices';
import { useAppointmentWebSocket } from '../hooks/useAppointmentWebSocket';
import Portal from '../components/Portal';
import EventAutomations from '../components/EventAutomations';
// Time settings
const START_HOUR = 0; // Midnight
@@ -1786,6 +1787,13 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
</div>
)}
{/* Automations - only show for saved appointments */}
{selectedAppointment.id && (
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<EventAutomations eventId={selectedAppointment.id} compact />
</div>
)}
{/* Action Buttons */}
<div className="pt-4 flex justify-end gap-3 border-t border-gray-200 dark:border-gray-700">
<button

View File

@@ -1,5 +1,6 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ShoppingBag,
@@ -19,7 +20,10 @@ import {
Package,
Eye,
ChevronDown,
AlertTriangle
AlertTriangle,
Clock,
Settings,
ArrowRight
} from 'lucide-react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
@@ -76,6 +80,7 @@ const detectPlatformOnlyCode = (code: string): { hasPlatformCode: boolean; warni
const PluginMarketplace: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<PluginCategory | 'ALL'>('ALL');
@@ -83,6 +88,8 @@ const PluginMarketplace: React.FC = () => {
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [showCode, setShowCode] = useState(false);
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [showWhatsNextModal, setShowWhatsNextModal] = useState(false);
const [installedPluginId, setInstalledPluginId] = useState<string | null>(null);
// Fetch marketplace plugins
const { data: plugins = [], isLoading, error } = useQuery<PluginTemplate[]>({
@@ -109,6 +116,20 @@ const PluginMarketplace: React.FC = () => {
},
});
// Fetch installed plugins to check which are already installed
const { data: installedPlugins = [] } = useQuery<{ template: number }[]>({
queryKey: ['plugin-installations'],
queryFn: async () => {
const { data } = await api.get('/api/plugin-installations/');
return data;
},
});
// Create a set of installed template IDs for quick lookup
const installedTemplateIds = useMemo(() => {
return new Set(installedPlugins.map(p => String(p.template)));
}, [installedPlugins]);
// Install plugin mutation
const installMutation = useMutation({
mutationFn: async (templateId: string) => {
@@ -117,10 +138,12 @@ const PluginMarketplace: React.FC = () => {
});
return data;
},
onSuccess: () => {
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
setShowDetailsModal(false);
setSelectedPlugin(null);
// Show What's Next modal
setInstalledPluginId(data.id);
setShowWhatsNextModal(true);
},
});
@@ -322,6 +345,12 @@ const PluginMarketplace: React.FC = () => {
Featured
</span>
)}
{installedTemplateIds.has(plugin.id) && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
<CheckCircle className="h-3 w-3" />
Installed
</span>
)}
</div>
{plugin.isVerified && (
<CheckCircle className="h-5 w-5 text-blue-500 flex-shrink-0" title="Verified" />
@@ -550,8 +579,22 @@ const PluginMarketplace: React.FC = () => {
}}
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')}
{installedTemplateIds.has(selectedPlugin.id) ? t('common.close', 'Close') : t('common.cancel', 'Cancel')}
</button>
{installedTemplateIds.has(selectedPlugin.id) ? (
<button
onClick={() => {
setShowDetailsModal(false);
setSelectedPlugin(null);
setShowCode(false);
navigate('/plugins/my-plugins');
}}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
>
<CheckCircle className="h-4 w-4" />
{t('plugins.viewInstalled', 'View in My Plugins')}
</button>
) : (
<button
onClick={confirmInstall}
disabled={installMutation.isPending || isLoadingDetails}
@@ -569,6 +612,91 @@ const PluginMarketplace: React.FC = () => {
</>
)}
</button>
)}
</div>
</div>
</div>
)}
{/* What's Next Modal - shown after successful install */}
{showWhatsNextModal && selectedPlugin && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-lg w-full shadow-2xl overflow-hidden">
{/* Success Header */}
<div className="bg-gradient-to-r from-green-500 to-emerald-600 px-6 py-8 text-center">
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-10 h-10 text-white" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">
Plugin Installed!
</h2>
<p className="text-green-100">
{selectedPlugin.name} is ready to use
</p>
</div>
{/* What's Next Options */}
<div className="p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
What would you like to do?
</h3>
{/* Schedule Task Option */}
<button
onClick={() => {
setShowWhatsNextModal(false);
setSelectedPlugin(null);
navigate('/tasks');
}}
className="w-full flex items-center gap-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-500 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors text-left group"
>
<div className="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<Clock className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<p className="font-semibold text-gray-900 dark:text-white">
Schedule it
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Run automatically on a schedule (hourly, daily, etc.)
</p>
</div>
<ArrowRight className="w-5 h-5 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors" />
</button>
{/* Configure Option */}
<button
onClick={() => {
setShowWhatsNextModal(false);
setSelectedPlugin(null);
navigate('/plugins/my-plugins');
}}
className="w-full flex items-center gap-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-purple-500 dark:hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-colors text-left group"
>
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<Settings className="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<p className="font-semibold text-gray-900 dark:text-white">
Configure settings
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Set up plugin options and customize behavior
</p>
</div>
<ArrowRight className="w-5 h-5 text-gray-400 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors" />
</button>
{/* Done Option */}
<button
onClick={() => {
setShowWhatsNextModal(false);
setSelectedPlugin(null);
}}
className="w-full p-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors text-center"
>
Done for now
</button>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from '../api/client';
@@ -10,13 +10,17 @@ import {
Edit,
Clock,
Calendar,
CalendarDays,
RotateCw,
CheckCircle2,
XCircle,
AlertCircle,
Zap,
Power,
} from 'lucide-react';
import toast from 'react-hot-toast';
import CreateTaskModal from '../components/CreateTaskModal';
import EditTaskModal from '../components/EditTaskModal';
// Types
interface ScheduledTask {
@@ -29,21 +33,70 @@ interface ScheduledTask {
cron_expression?: string;
interval_minutes?: number;
run_at?: string;
next_run_time?: string;
last_run_time?: string;
is_active: boolean;
next_run_at?: string;
last_run_at?: string;
status: 'ACTIVE' | 'PAUSED' | 'DISABLED';
last_run_status?: string;
plugin_config: Record<string, any>;
created_at: string;
updated_at: string;
}
interface PluginInstallation {
id: string;
template: number;
template_name: string;
template_slug: string;
template_description: string;
category: string;
version: string;
author_name: string;
logo_url?: string;
template_variables: Record<string, any>;
scheduled_task?: number;
scheduled_task_name?: string;
installed_at: string;
config_values: Record<string, any>;
has_update: boolean;
}
interface GlobalEventPlugin {
id: string;
plugin_installation: number;
plugin_name: string;
plugin_description: string;
plugin_category: string;
plugin_logo_url?: string;
trigger: string;
trigger_display: string;
offset_minutes: number;
timing_description: string;
is_active: boolean;
apply_to_existing: boolean;
execution_order: number;
events_count: number;
created_at: string;
updated_at: string;
}
// Unified task type for display
type UnifiedTask = {
type: 'scheduled';
data: ScheduledTask;
} | {
type: 'event';
data: GlobalEventPlugin;
};
const Tasks: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingTask, setEditingTask] = useState<ScheduledTask | null>(null);
const [editingEventAutomation, setEditingEventAutomation] = useState<GlobalEventPlugin | null>(null);
// Fetch tasks
const { data: tasks = [], isLoading } = useQuery<ScheduledTask[]>({
// Fetch scheduled tasks
const { data: scheduledTasks = [], isLoading: tasksLoading } = useQuery<ScheduledTask[]>({
queryKey: ['scheduled-tasks'],
queryFn: async () => {
const { data } = await axios.get('/api/scheduled-tasks/');
@@ -51,6 +104,28 @@ const Tasks: React.FC = () => {
},
});
// Fetch global event plugins (event automations)
const { data: eventAutomations = [], isLoading: automationsLoading } = useQuery<GlobalEventPlugin[]>({
queryKey: ['global-event-plugins'],
queryFn: async () => {
const { data } = await axios.get('/api/global-event-plugins/');
return data;
},
});
// Combine into unified list
const allTasks: UnifiedTask[] = useMemo(() => {
const scheduled: UnifiedTask[] = scheduledTasks.map(t => ({ type: 'scheduled' as const, data: t }));
const events: UnifiedTask[] = eventAutomations.map(e => ({ type: 'event' as const, data: e }));
return [...scheduled, ...events].sort((a, b) => {
const dateA = new Date(a.data.created_at).getTime();
const dateB = new Date(b.data.created_at).getTime();
return dateB - dateA; // Most recent first
});
}, [scheduledTasks, eventAutomations]);
const isLoading = tasksLoading || automationsLoading;
// Delete task
const deleteMutation = useMutation({
mutationFn: async (taskId: string) => {
@@ -67,8 +142,8 @@ const Tasks: React.FC = () => {
// Toggle task active status
const toggleActiveMutation = useMutation({
mutationFn: async ({ taskId, isActive }: { taskId: string; isActive: boolean }) => {
await axios.patch(`/api/scheduled-tasks/${taskId}/`, { is_active: isActive });
mutationFn: async ({ taskId, status }: { taskId: string; status: 'ACTIVE' | 'PAUSED' }) => {
await axios.patch(`/api/scheduled-tasks/${taskId}/`, { status });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] });
@@ -92,6 +167,49 @@ const Tasks: React.FC = () => {
},
});
// Delete event automation
const deleteEventAutomationMutation = useMutation({
mutationFn: async (automationId: string) => {
await axios.delete(`/api/global-event-plugins/${automationId}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
toast.success('Event automation deleted');
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to delete automation');
},
});
// Toggle event automation active status
const toggleEventAutomationMutation = useMutation({
mutationFn: async (automationId: string) => {
await axios.post(`/api/global-event-plugins/${automationId}/toggle/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
toast.success('Automation updated');
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to update automation');
},
});
// Update event automation
const updateEventAutomationMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<GlobalEventPlugin> }) => {
await axios.patch(`/api/global-event-plugins/${id}/`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
toast.success('Automation updated');
setEditingEventAutomation(null);
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to update automation');
},
});
const getScheduleDisplay = (task: ScheduledTask): string => {
if (task.schedule_type === 'ONE_TIME') {
return `Once on ${new Date(task.run_at!).toLocaleString()}`;
@@ -117,14 +235,14 @@ const Tasks: React.FC = () => {
};
const getStatusColor = (task: ScheduledTask): string => {
if (!task.is_active) return 'text-gray-400 dark:text-gray-500';
if (task.last_run_time) return 'text-green-600 dark:text-green-400';
if (task.status === 'PAUSED' || task.status === 'DISABLED') return 'text-gray-400 dark:text-gray-500';
if (task.last_run_at) return 'text-green-600 dark:text-green-400';
return 'text-blue-600 dark:text-blue-400';
};
const getStatusIcon = (task: ScheduledTask) => {
if (!task.is_active) return Pause;
if (task.last_run_time) return CheckCircle2;
if (task.status === 'PAUSED' || task.status === 'DISABLED') return Pause;
if (task.last_run_at) return CheckCircle2;
return AlertCircle;
};
@@ -166,7 +284,7 @@ const Tasks: React.FC = () => {
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Total Tasks</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{tasks.length}</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{allTasks.length}</p>
</div>
</div>
</div>
@@ -179,21 +297,7 @@ const Tasks: React.FC = () => {
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Active</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{tasks.filter(t => t.is_active).length}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
<Pause className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Paused</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{tasks.filter(t => !t.is_active).length}
{scheduledTasks.filter(t => t.status === 'ACTIVE').length + eventAutomations.filter(e => e.is_active).length}
</p>
</div>
</div>
@@ -202,12 +306,26 @@ const Tasks: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<RotateCw className="w-5 h-5 text-purple-600 dark:text-purple-400" />
<CalendarDays className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Recurring</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Event Automations</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{tasks.filter(t => t.schedule_type !== 'ONE_TIME').length}
{eventAutomations.length}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<RotateCw className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Scheduled</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{scheduledTasks.length}
</p>
</div>
</div>
@@ -215,7 +333,7 @@ const Tasks: React.FC = () => {
</div>
{/* Tasks List */}
{tasks.length === 0 ? (
{allTasks.length === 0 ? (
<div className="text-center py-16 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<Zap className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
@@ -234,18 +352,23 @@ const Tasks: React.FC = () => {
</div>
) : (
<div className="space-y-4">
{tasks.map((task) => {
{allTasks.map((unifiedTask) => {
if (unifiedTask.type === 'scheduled') {
const task = unifiedTask.data;
const ScheduleIcon = getScheduleIcon(task);
const StatusIcon = getStatusIcon(task);
return (
<div
key={task.id}
key={`scheduled-${task.id}`}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded">
Scheduled
</span>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{task.name}
</h3>
@@ -269,17 +392,17 @@ const Tasks: React.FC = () => {
<span>{getScheduleDisplay(task)}</span>
</div>
{task.next_run_time && task.is_active && (
{task.next_run_at && task.status === 'ACTIVE' && (
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4" />
<span>Next: {new Date(task.next_run_time).toLocaleString()}</span>
<span>Next: {new Date(task.next_run_at).toLocaleString()}</span>
</div>
)}
{task.last_run_time && (
{task.last_run_at && (
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
<CheckCircle2 className="w-4 h-4" />
<span>Last: {new Date(task.last_run_time).toLocaleString()}</span>
<span>Last: {new Date(task.last_run_at).toLocaleString()}</span>
</div>
)}
</div>
@@ -298,20 +421,20 @@ const Tasks: React.FC = () => {
<button
onClick={() => toggleActiveMutation.mutate({
taskId: task.id,
isActive: !task.is_active
status: task.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE'
})}
className={`p-2 rounded-lg transition-colors ${
task.is_active
task.status === 'ACTIVE'
? 'text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20'
: 'text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20'
}`}
title={task.is_active ? 'Pause' : 'Resume'}
title={task.status === 'ACTIVE' ? 'Pause' : 'Resume'}
>
{task.is_active ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
{task.status === 'ACTIVE' ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</button>
<button
onClick={() => {/* TODO: Edit modal */}}
onClick={() => setEditingTask(task)}
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Edit"
>
@@ -333,33 +456,322 @@ const Tasks: React.FC = () => {
</div>
</div>
);
})}
} else {
// Event automation
const automation = unifiedTask.data;
return (
<div
key={`event-${automation.id}`}
className={`bg-white dark:bg-gray-800 rounded-lg border p-6 hover:shadow-md transition-shadow ${
automation.is_active
? 'border-gray-200 dark:border-gray-700'
: 'border-gray-200 dark:border-gray-700 opacity-60'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded">
Event Automation
</span>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{automation.plugin_name}
</h3>
{automation.is_active ? (
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
) : (
<Pause className="w-5 h-5 text-gray-400" />
)}
</div>
{automation.plugin_description && (
<p className="text-gray-600 dark:text-gray-400 text-sm mb-3">
{automation.plugin_description}
</p>
)}
{/* TODO: Create/Edit Task Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Create New Task
</h2>
<p className="text-gray-600 dark:text-gray-400">
Task creation form coming soon...
</p>
<div className="mt-6 flex justify-end gap-3">
<div className="flex flex-wrap items-center gap-4 text-sm">
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
<CalendarDays className="w-4 h-4" />
<span>{automation.timing_description}</span>
</div>
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
<Zap className="w-4 h-4" />
<span>{automation.events_count} events</span>
</div>
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
<span className={`px-2 py-0.5 text-xs rounded ${
automation.apply_to_existing
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
{automation.apply_to_existing ? 'All events' : 'Future only'}
</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
onClick={() => toggleEventAutomationMutation.mutate(automation.id)}
className={`p-2 rounded-lg transition-colors ${
automation.is_active
? 'text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20'
: 'text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20'
}`}
title={automation.is_active ? 'Disable' : 'Enable'}
>
Cancel
<Power className="w-5 h-5" />
</button>
<button
onClick={() => setEditingEventAutomation(automation)}
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => {
if (confirm('Are you sure you want to delete this automation? It will be removed from all events.')) {
deleteEventAutomationMutation.mutate(automation.id);
}
}}
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="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
}
})}
</div>
)}
{/* Create Task Modal */}
<CreateTaskModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] });
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
}}
/>
{/* Edit Task Modal */}
<EditTaskModal
task={editingTask}
isOpen={!!editingTask}
onClose={() => setEditingTask(null)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] });
toast.success('Task updated successfully');
}}
/>
{/* Edit Event Automation Modal */}
{editingEventAutomation && (
<EditEventAutomationModal
automation={editingEventAutomation}
isOpen={!!editingEventAutomation}
onClose={() => setEditingEventAutomation(null)}
onSave={(data) => updateEventAutomationMutation.mutate({ id: editingEventAutomation.id, data })}
isLoading={updateEventAutomationMutation.isPending}
/>
)}
</div>
);
};
// Inline Edit Event Automation Modal component
interface EditEventAutomationModalProps {
automation: GlobalEventPlugin;
isOpen: boolean;
onClose: () => void;
onSave: (data: Partial<GlobalEventPlugin>) => void;
isLoading: boolean;
}
const TRIGGER_OPTIONS = [
{ value: 'before_start', label: 'Before Start' },
{ value: 'at_start', label: 'At Start' },
{ value: 'after_start', label: 'After Start' },
{ value: 'after_end', label: 'After End' },
{ value: 'on_complete', label: 'When Completed' },
{ value: 'on_cancel', label: 'When Canceled' },
];
const OFFSET_PRESETS = [
{ value: 0, label: 'Immediately' },
{ value: 5, label: '5 min' },
{ value: 10, label: '10 min' },
{ value: 15, label: '15 min' },
{ value: 30, label: '30 min' },
{ value: 60, label: '1 hour' },
];
const EditEventAutomationModal: React.FC<EditEventAutomationModalProps> = ({
automation,
isOpen,
onClose,
onSave,
isLoading,
}) => {
const [trigger, setTrigger] = useState(automation.trigger);
const [offsetMinutes, setOffsetMinutes] = useState(automation.offset_minutes);
const showOffset = !['on_complete', 'on_cancel'].includes(trigger);
const getTimingDescription = () => {
if (trigger === 'on_complete') return 'When event is completed';
if (trigger === 'on_cancel') return 'When event is canceled';
if (offsetMinutes === 0) {
if (trigger === 'before_start' || trigger === 'at_start' || trigger === 'after_start') return 'At event start';
if (trigger === 'after_end') return 'At event end';
}
const offsetLabel = OFFSET_PRESETS.find(o => o.value === offsetMinutes)?.label || `${offsetMinutes} min`;
if (trigger === 'before_start') return `${offsetLabel} before event starts`;
if (trigger === 'at_start' || trigger === 'after_start') return `${offsetLabel} after event starts`;
if (trigger === 'after_end') return `${offsetLabel} after event ends`;
return 'Unknown timing';
};
const handleSave = () => {
onSave({
trigger,
offset_minutes: offsetMinutes,
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-lg w-full">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Edit Event Automation
</h2>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<XCircle className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Plugin info */}
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg">
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-purple-600 dark:text-purple-400" />
<div>
<p className="text-sm text-purple-800 dark:text-purple-200 font-medium">
Plugin
</p>
<p className="text-purple-900 dark:text-purple-100">
{automation.plugin_name}
</p>
</div>
</div>
</div>
{/* When to run */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
When should it run?
</label>
<div className="grid grid-cols-2 gap-2">
{TRIGGER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
setTrigger(opt.value);
if (['on_complete', 'on_cancel'].includes(opt.value)) {
setOffsetMinutes(0);
}
}}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
trigger === opt.value
? 'bg-purple-600 text-white border-purple-600'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-purple-400'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Offset selection */}
{showOffset && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
How long {trigger === 'before_start' ? 'before' : 'after'}?
</label>
<div className="flex flex-wrap gap-2">
{OFFSET_PRESETS.map((preset) => (
<button
key={preset.value}
type="button"
onClick={() => setOffsetMinutes(preset.value)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
offsetMinutes === preset.value
? 'bg-purple-600 text-white border-purple-600'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-purple-400'
}`}
>
{preset.label}
</button>
))}
</div>
</div>
)}
{/* Preview */}
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 border border-purple-300 dark:border-purple-700 rounded-lg">
<div className="flex items-center gap-2">
<CalendarDays className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm text-purple-800 dark:text-purple-200">
<strong>Runs:</strong> {getTimingDescription()}
</span>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isLoading}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
);
};
export default Tasks;

View File

@@ -18,3 +18,11 @@ class ScheduleConfig(AppConfig):
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to load builtin plugins: {e}")
# Import signals to register them
try:
from . import signals # noqa: F401
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to load signals: {e}")

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.2.8 on 2025-11-29 16:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0019_alter_event_status_eventplugin_event_plugins'),
]
operations = [
migrations.AlterUniqueTogether(
name='eventplugin',
unique_together=set(),
),
migrations.AddField(
model_name='eventplugin',
name='offset_minutes',
field=models.PositiveIntegerField(default=0, help_text="Minutes offset from trigger time (e.g., 10 = '10 minutes before/after')"),
),
migrations.AlterField(
model_name='eventplugin',
name='event',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_plugins', to='schedule.event'),
),
migrations.AlterField(
model_name='eventplugin',
name='trigger',
field=models.CharField(choices=[('before_start', 'Before Start'), ('at_start', 'At Start'), ('after_start', 'After Start'), ('after_end', 'After End'), ('on_complete', 'When Completed'), ('on_cancel', 'When Canceled')], default='at_start', help_text='When this plugin should execute', max_length=20),
),
migrations.AlterUniqueTogether(
name='eventplugin',
unique_together={('event', 'plugin_installation', 'trigger', 'offset_minutes')},
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.8 on 2025-11-29 17:00
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0020_add_eventplugin_offset'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='GlobalEventPlugin',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('trigger', models.CharField(choices=[('before_start', 'Before Start'), ('at_start', 'At Start'), ('after_start', 'After Start'), ('after_end', 'After End'), ('on_complete', 'When Completed'), ('on_cancel', 'When Canceled')], default='at_start', help_text='When this plugin should execute for each event', max_length=20)),
('offset_minutes', models.PositiveIntegerField(default=0, help_text='Minutes offset from trigger time')),
('is_active', models.BooleanField(default=True, help_text='Whether this rule is active')),
('execution_order', models.PositiveSmallIntegerField(default=0, help_text='Order of execution (lower numbers run first)')),
('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_global_event_plugins', to=settings.AUTH_USER_MODEL)),
('plugin_installation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_event_rules', to='schedule.plugininstallation')),
],
options={
'ordering': ['execution_order', 'created_at'],
'unique_together': {('plugin_installation', 'trigger', 'offset_minutes')},
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-11-29 17:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0021_add_global_event_plugin'),
]
operations = [
migrations.AddField(
model_name='globaleventplugin',
name='apply_to_existing',
field=models.BooleanField(default=True, help_text='Whether to apply this rule to existing events when created'),
),
]

View File

@@ -270,25 +270,38 @@ class EventPlugin(models.Model):
"""
Through model for Event-Plugin relationship.
Allows configuring when and how a plugin runs for an event.
Timing works as follows:
- BEFORE_START with offset_minutes=10 means "10 minutes before event starts"
- AT_START with offset_minutes=0 means "when event starts"
- AFTER_START with offset_minutes=15 means "15 minutes after event starts"
- AFTER_END with offset_minutes=0 means "when event ends"
- ON_COMPLETE means "when status changes to completed"
- ON_CANCEL means "when status changes to canceled"
"""
class Trigger(models.TextChoices):
EVENT_CREATED = 'event_created', 'When Event is Created'
EVENT_UPDATED = 'event_updated', 'When Event is Updated'
EVENT_COMPLETED = 'event_completed', 'When Event is Completed'
EVENT_CANCELED = 'event_canceled', 'When Event is Canceled'
BEFORE_START = 'before_start', 'Before Event Starts'
AFTER_END = 'after_end', 'After Event Ends'
BEFORE_START = 'before_start', 'Before Start'
AT_START = 'at_start', 'At Start'
AFTER_START = 'after_start', 'After Start'
AFTER_END = 'after_end', 'After End'
ON_COMPLETE = 'on_complete', 'When Completed'
ON_CANCEL = 'on_cancel', 'When Canceled'
event = models.ForeignKey(Event, on_delete=models.CASCADE)
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='event_plugins')
plugin_installation = models.ForeignKey('PluginInstallation', on_delete=models.CASCADE)
trigger = models.CharField(
max_length=20,
choices=Trigger.choices,
default=Trigger.EVENT_CREATED,
default=Trigger.AT_START,
help_text="When this plugin should execute"
)
offset_minutes = models.PositiveIntegerField(
default=0,
help_text="Minutes offset from trigger time (e.g., 10 = '10 minutes before/after')"
)
is_active = models.BooleanField(
default=True,
help_text="Whether this plugin should run for this event"
@@ -303,11 +316,132 @@ class EventPlugin(models.Model):
class Meta:
ordering = ['execution_order', 'created_at']
unique_together = ['event', 'plugin_installation', 'trigger']
# Allow same plugin with different triggers, but not duplicate trigger+offset
unique_together = ['event', 'plugin_installation', 'trigger', 'offset_minutes']
def __str__(self):
plugin_name = self.plugin_installation.template.name if self.plugin_installation.template else 'Unknown'
return f"{self.event.title} - {plugin_name} ({self.trigger})"
offset_str = f" (+{self.offset_minutes}m)" if self.offset_minutes else ""
return f"{self.event.title} - {plugin_name} ({self.get_trigger_display()}{offset_str})"
def get_execution_time(self):
"""Calculate the actual execution time based on trigger and offset"""
from datetime import timedelta
if self.trigger == self.Trigger.BEFORE_START:
return self.event.start_time - timedelta(minutes=self.offset_minutes)
elif self.trigger == self.Trigger.AT_START:
return self.event.start_time + timedelta(minutes=self.offset_minutes)
elif self.trigger == self.Trigger.AFTER_START:
return self.event.start_time + timedelta(minutes=self.offset_minutes)
elif self.trigger == self.Trigger.AFTER_END:
return self.event.end_time + timedelta(minutes=self.offset_minutes)
else:
# ON_COMPLETE and ON_CANCEL are event-driven, not time-driven
return None
class GlobalEventPlugin(models.Model):
"""
Defines a rule for automatically attaching a plugin to ALL events.
When created, this rule:
1. Attaches the plugin to all existing events
2. Automatically attaches to new events as they are created
The actual EventPlugin instances are created/managed by signals.
"""
plugin_installation = models.ForeignKey(
'PluginInstallation',
on_delete=models.CASCADE,
related_name='global_event_rules'
)
trigger = models.CharField(
max_length=20,
choices=EventPlugin.Trigger.choices,
default=EventPlugin.Trigger.AT_START,
help_text="When this plugin should execute for each event"
)
offset_minutes = models.PositiveIntegerField(
default=0,
help_text="Minutes offset from trigger time"
)
is_active = models.BooleanField(
default=True,
help_text="Whether this rule is active"
)
apply_to_existing = models.BooleanField(
default=True,
help_text="Whether to apply this rule to existing events when created"
)
execution_order = models.PositiveSmallIntegerField(
default=0,
help_text="Order of execution (lower numbers run first)"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
related_name='created_global_event_plugins'
)
class Meta:
ordering = ['execution_order', 'created_at']
# Prevent duplicate rules
unique_together = ['plugin_installation', 'trigger', 'offset_minutes']
def __str__(self):
plugin_name = self.plugin_installation.template.name if self.plugin_installation.template else 'Unknown'
offset_str = f" (+{self.offset_minutes}m)" if self.offset_minutes else ""
return f"Global: {plugin_name} ({self.get_trigger_display()}{offset_str})"
def apply_to_event(self, event):
"""
Apply this global rule to a specific event by creating an EventPlugin.
Returns the created EventPlugin or None if it already exists.
"""
event_plugin, created = EventPlugin.objects.get_or_create(
event=event,
plugin_installation=self.plugin_installation,
trigger=self.trigger,
offset_minutes=self.offset_minutes,
defaults={
'is_active': self.is_active,
'execution_order': self.execution_order,
}
)
return event_plugin if created else None
def apply_to_all_events(self):
"""
Apply this global rule to all existing events.
Called when the GlobalEventPlugin is created.
"""
from django.db.models import Q
# Get all events that don't already have this plugin with same trigger/offset
existing_combinations = EventPlugin.objects.filter(
plugin_installation=self.plugin_installation,
trigger=self.trigger,
offset_minutes=self.offset_minutes,
).values_list('event_id', flat=True)
events = Event.objects.exclude(id__in=existing_combinations)
created_count = 0
for event in events:
if self.apply_to_event(event):
created_count += 1
return created_count
class Participant(models.Model):

View File

@@ -4,7 +4,7 @@ DRF Serializers for Schedule App with Availability Validation
from rest_framework import serializers
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin
from .services import AvailabilityService
from smoothschedule.users.models import User
@@ -702,3 +702,186 @@ class PluginInstallationSerializer(serializers.ModelSerializer):
validated_data.pop('scheduled_task', None)
return super().create(validated_data)
class EventPluginSerializer(serializers.ModelSerializer):
"""
Serializer for EventPlugin - attaching plugins to calendar events.
Provides a visual-friendly representation of when plugins run:
- trigger: 'before_start', 'at_start', 'after_start', 'after_end', 'on_complete', 'on_cancel'
- offset_minutes: 0, 5, 10, 15, 30, 60 (for time-based triggers)
"""
plugin_name = serializers.CharField(source='plugin_installation.template.name', read_only=True)
plugin_description = serializers.CharField(source='plugin_installation.template.short_description', read_only=True)
plugin_category = serializers.CharField(source='plugin_installation.template.category', read_only=True)
plugin_logo_url = serializers.CharField(source='plugin_installation.template.logo_url', read_only=True)
trigger_display = serializers.CharField(source='get_trigger_display', read_only=True)
execution_time = serializers.SerializerMethodField()
timing_description = serializers.SerializerMethodField()
class Meta:
model = EventPlugin
fields = [
'id',
'event',
'plugin_installation',
'plugin_name',
'plugin_description',
'plugin_category',
'plugin_logo_url',
'trigger',
'trigger_display',
'offset_minutes',
'timing_description',
'execution_time',
'is_active',
'execution_order',
'created_at',
]
read_only_fields = ['id', 'created_at']
def get_execution_time(self, obj):
"""Get the calculated execution time"""
exec_time = obj.get_execution_time()
return exec_time.isoformat() if exec_time else None
def get_timing_description(self, obj):
"""
Generate a human-readable description of when the plugin runs.
Examples: "At start", "10 minutes before start", "30 minutes after end"
"""
trigger = obj.trigger
offset = obj.offset_minutes
if trigger == EventPlugin.Trigger.BEFORE_START:
if offset == 0:
return "At start"
return f"{offset} min before start"
elif trigger == EventPlugin.Trigger.AT_START:
if offset == 0:
return "At start"
return f"{offset} min after start"
elif trigger == EventPlugin.Trigger.AFTER_START:
if offset == 0:
return "At start"
return f"{offset} min after start"
elif trigger == EventPlugin.Trigger.AFTER_END:
if offset == 0:
return "At end"
return f"{offset} min after end"
elif trigger == EventPlugin.Trigger.ON_COMPLETE:
return "When completed"
elif trigger == EventPlugin.Trigger.ON_CANCEL:
return "When canceled"
return "Unknown"
def validate(self, attrs):
"""Validate that offset makes sense for the trigger type"""
trigger = attrs.get('trigger', EventPlugin.Trigger.AT_START)
offset = attrs.get('offset_minutes', 0)
# Event-driven triggers don't use offset
if trigger in [EventPlugin.Trigger.ON_COMPLETE, EventPlugin.Trigger.ON_CANCEL]:
if offset != 0:
attrs['offset_minutes'] = 0 # Auto-correct instead of error
return attrs
class GlobalEventPluginSerializer(serializers.ModelSerializer):
"""
Serializer for GlobalEventPlugin - rules for auto-attaching plugins to ALL events.
When created, automatically applies to:
1. All existing events
2. All future events as they are created
"""
plugin_name = serializers.CharField(source='plugin_installation.template.name', read_only=True)
plugin_description = serializers.CharField(source='plugin_installation.template.short_description', read_only=True)
plugin_category = serializers.CharField(source='plugin_installation.template.category', read_only=True)
plugin_logo_url = serializers.CharField(source='plugin_installation.template.logo_url', read_only=True)
trigger_display = serializers.CharField(source='get_trigger_display', read_only=True)
timing_description = serializers.SerializerMethodField()
events_count = serializers.SerializerMethodField()
class Meta:
model = GlobalEventPlugin
fields = [
'id',
'plugin_installation',
'plugin_name',
'plugin_description',
'plugin_category',
'plugin_logo_url',
'trigger',
'trigger_display',
'offset_minutes',
'timing_description',
'is_active',
'apply_to_existing',
'execution_order',
'events_count',
'created_at',
'updated_at',
'created_by',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'created_by']
def get_timing_description(self, obj):
"""Generate a human-readable description of when the plugin runs."""
trigger = obj.trigger
offset = obj.offset_minutes
if trigger == 'before_start':
if offset == 0:
return "At start"
return f"{offset} min before start"
elif trigger == 'at_start':
if offset == 0:
return "At start"
return f"{offset} min after start"
elif trigger == 'after_start':
if offset == 0:
return "At start"
return f"{offset} min after start"
elif trigger == 'after_end':
if offset == 0:
return "At end"
return f"{offset} min after end"
elif trigger == 'on_complete':
return "When completed"
elif trigger == 'on_cancel':
return "When canceled"
return "Unknown"
def get_events_count(self, obj):
"""Get the count of events this rule applies to."""
return EventPlugin.objects.filter(
plugin_installation=obj.plugin_installation,
trigger=obj.trigger,
offset_minutes=obj.offset_minutes,
).count()
def validate(self, attrs):
"""Validate the global event plugin configuration."""
trigger = attrs.get('trigger', 'at_start')
offset = attrs.get('offset_minutes', 0)
# Event-driven triggers don't use offset
if trigger in ['on_complete', 'on_cancel']:
if offset != 0:
attrs['offset_minutes'] = 0
return attrs
def create(self, validated_data):
"""Create the global rule and apply to existing events."""
# Set the created_by from request context
request = self.context.get('request')
if request and hasattr(request, 'user'):
validated_data['created_by'] = request.user
return super().create(validated_data)

View File

@@ -0,0 +1,211 @@
"""
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)
"""
import logging
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
logger = logging.getLogger(__name__)
@receiver(post_save, sender='schedule.Event')
def auto_attach_global_plugins(sender, instance, created, **kwargs):
"""
When a new event is created, automatically attach all active GlobalEventPlugin rules.
"""
if not created:
return
from .models import GlobalEventPlugin, EventPlugin
# Get all active global rules
global_rules = GlobalEventPlugin.objects.filter(is_active=True)
attached_count = 0
for rule in global_rules:
event_plugin = rule.apply_to_event(instance)
if event_plugin:
attached_count += 1
logger.info(
f"Auto-attached plugin '{rule.plugin_installation}' to event '{instance}' "
f"via global rule (trigger={rule.trigger}, offset={rule.offset_minutes})"
)
if attached_count > 0:
logger.info(f"Auto-attached {attached_count} plugins to new event '{instance}'")
@receiver(pre_save, sender='schedule.Event')
def track_event_changes(sender, instance, **kwargs):
"""
Track changes to event timing before save.
Store old values on the instance for post_save comparison.
"""
if instance.pk:
try:
from .models import Event
old_instance = Event.objects.get(pk=instance.pk)
instance._old_start_time = old_instance.start_time
instance._old_end_time = old_instance.end_time
except sender.DoesNotExist:
instance._old_start_time = None
instance._old_end_time = None
else:
instance._old_start_time = None
instance._old_end_time = None
@receiver(post_save, sender='schedule.Event')
def reschedule_event_plugins_on_change(sender, instance, created, **kwargs):
"""
When an event's timing changes, update any scheduled Celery tasks for its plugins.
This handles both time changes and duration changes (via end_time).
"""
if created:
# New events don't have existing tasks to reschedule
return
old_start = getattr(instance, '_old_start_time', None)
old_end = getattr(instance, '_old_end_time', None)
if old_start is None and old_end is None:
return
# Check if timing actually changed
start_changed = old_start and old_start != instance.start_time
end_changed = old_end and old_end != instance.end_time
if not start_changed and not end_changed:
return
logger.info(
f"Event '{instance}' timing changed. "
f"Start: {old_start} -> {instance.start_time}, "
f"End: {old_end} -> {instance.end_time}"
)
# Reschedule all active time-based event plugins
reschedule_event_celery_tasks(instance, start_changed, end_changed)
def reschedule_event_celery_tasks(event, start_changed=True, end_changed=True):
"""
Reschedule Celery tasks for an event's plugins when timing changes.
Args:
event: The Event instance
start_changed: Whether start_time changed
end_changed: Whether end_time changed
"""
from .models import EventPlugin
# Get all active, time-based plugins for this event
time_based_triggers = ['before_start', 'at_start', 'after_start', 'after_end']
plugins_to_update = event.event_plugins.filter(
is_active=True,
trigger__in=time_based_triggers
)
for event_plugin in plugins_to_update:
# Only reschedule if the relevant time changed
affects_start = event_plugin.trigger in ['before_start', 'at_start', 'after_start']
affects_end = event_plugin.trigger == 'after_end'
if (affects_start and start_changed) or (affects_end and end_changed):
new_execution_time = event_plugin.get_execution_time()
if new_execution_time:
logger.info(
f"Rescheduling plugin '{event_plugin.plugin_installation}' for event '{event}' "
f"to new execution time: {new_execution_time}"
)
# TODO: Integrate with Celery beat to reschedule the actual task
# For now, we log the intent. The actual Celery integration
# will be handled by the task execution system.
schedule_event_plugin_task(event_plugin, new_execution_time)
def schedule_event_plugin_task(event_plugin, execution_time):
"""
Schedule a Celery task for an event plugin at a specific time.
This function handles creating or updating Celery beat entries
for time-based event plugin execution.
"""
from django.utils import timezone
# Don't schedule tasks in the past
if execution_time < timezone.now():
logger.debug(
f"Skipping scheduling for event plugin {event_plugin.id} - "
f"execution time {execution_time} is in the past"
)
return
# Get or create the Celery task entry
# Using django-celery-beat's PeriodicTask model if available
try:
from django_celery_beat.models import PeriodicTask, ClockedSchedule
# Create a clocked schedule for the specific execution time
clocked_schedule, _ = ClockedSchedule.objects.get_or_create(
clocked_time=execution_time
)
# Task name is unique per event-plugin combination
task_name = f"event_plugin_{event_plugin.id}"
# Create or update the periodic task
task, created = PeriodicTask.objects.update_or_create(
name=task_name,
defaults={
'task': 'schedule.tasks.execute_event_plugin',
'clocked': clocked_schedule,
'one_off': True, # Run only once
'enabled': event_plugin.is_active,
'kwargs': str({
'event_plugin_id': event_plugin.id,
'event_id': event_plugin.event_id,
}),
}
)
action = "Created" if created else "Updated"
logger.info(f"{action} Celery task '{task_name}' for execution at {execution_time}")
except ImportError:
# django-celery-beat not installed, fall back to simple delay
logger.warning(
"django-celery-beat not installed. "
"Event plugin scheduling will use basic Celery delay."
)
except Exception as e:
logger.error(f"Failed to schedule event plugin task: {e}")
@receiver(post_save, sender='schedule.GlobalEventPlugin')
def apply_global_plugin_to_existing_events(sender, instance, created, **kwargs):
"""
When a new GlobalEventPlugin rule is created, apply it to all existing events
if apply_to_existing is True.
"""
if not created:
return
if not instance.is_active:
return
if not instance.apply_to_existing:
logger.info(
f"Global plugin rule '{instance}' will only apply to future events"
)
return
count = instance.apply_to_all_events()
logger.info(
f"Applied global plugin rule '{instance}' to {count} existing events"
)

View File

@@ -7,7 +7,8 @@ from .views import (
ResourceViewSet, EventViewSet, ParticipantViewSet,
CustomerViewSet, ServiceViewSet, StaffViewSet, ResourceTypeViewSet,
ScheduledTaskViewSet, TaskExecutionLogViewSet, PluginViewSet,
PluginTemplateViewSet, PluginInstallationViewSet
PluginTemplateViewSet, PluginInstallationViewSet, EventPluginViewSet,
GlobalEventPluginViewSet
)
# Create router and register viewsets
@@ -25,6 +26,8 @@ router.register(r'task-logs', TaskExecutionLogViewSet, basename='tasklog')
router.register(r'plugins', PluginViewSet, basename='plugin')
router.register(r'plugin-templates', PluginTemplateViewSet, basename='plugintemplate')
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')
# URL patterns
urlpatterns = [

View File

@@ -8,12 +8,13 @@ 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
from .models import Resource, Event, Participant, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin
from .serializers import (
ResourceSerializer, EventSerializer, ParticipantSerializer,
CustomerSerializer, ServiceSerializer, ResourceTypeSerializer, StaffSerializer,
ScheduledTaskSerializer, TaskExecutionLogSerializer, PluginInfoSerializer,
PluginTemplateSerializer, PluginTemplateListSerializer, PluginInstallationSerializer
PluginTemplateSerializer, PluginTemplateListSerializer, PluginInstallationSerializer,
EventPluginSerializer, GlobalEventPluginSerializer
)
from .models import Service
from core.permissions import HasQuota
@@ -1031,3 +1032,211 @@ class PluginInstallationViewSet(viewsets.ModelViewSet):
return Response({
'message': 'Plugin uninstalled successfully'
}, status=status.HTTP_204_NO_CONTENT)
class EventPluginViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing plugins attached to calendar events.
This allows users to attach installed plugins to events with configurable
timing triggers (before start, at start, after end, on complete, etc.)
Endpoints:
- GET /api/event-plugins/?event_id=X - List plugins for an event
- POST /api/event-plugins/ - Attach plugin to event
- PATCH /api/event-plugins/{id}/ - Update timing/trigger
- DELETE /api/event-plugins/{id}/ - Remove plugin from event
- POST /api/event-plugins/{id}/toggle/ - Enable/disable plugin
"""
queryset = EventPlugin.objects.select_related(
'event',
'plugin_installation',
'plugin_installation__template'
).all()
serializer_class = EventPluginSerializer
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated
def get_queryset(self):
"""Filter by event if specified"""
queryset = super().get_queryset()
event_id = self.request.query_params.get('event_id')
if event_id:
queryset = queryset.filter(event_id=event_id)
return queryset.order_by('execution_order', 'created_at')
def list(self, request):
"""
List event plugins.
Query params:
- event_id: Filter by event (required for listing)
"""
event_id = request.query_params.get('event_id')
if not event_id:
return Response({
'error': 'event_id query parameter is required'
}, status=status.HTTP_400_BAD_REQUEST)
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def toggle(self, request, pk=None):
"""Toggle is_active status of an event plugin"""
event_plugin = self.get_object()
event_plugin.is_active = not event_plugin.is_active
event_plugin.save(update_fields=['is_active'])
serializer = self.get_serializer(event_plugin)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def triggers(self, request):
"""
Get available trigger options for the UI.
Returns trigger choices with human-readable labels and
common offset presets.
"""
return Response({
'triggers': [
{'value': choice[0], 'label': choice[1]}
for choice in EventPlugin.Trigger.choices
],
'offset_presets': [
{'value': 0, 'label': 'Immediately'},
{'value': 5, 'label': '5 minutes'},
{'value': 10, 'label': '10 minutes'},
{'value': 15, 'label': '15 minutes'},
{'value': 30, 'label': '30 minutes'},
{'value': 60, 'label': '1 hour'},
{'value': 120, 'label': '2 hours'},
{'value': 1440, 'label': '1 day'},
],
'timing_groups': [
{
'label': 'Before Event',
'triggers': ['before_start'],
'supports_offset': True,
},
{
'label': 'During Event',
'triggers': ['at_start', 'after_start'],
'supports_offset': True,
},
{
'label': 'After Event',
'triggers': ['after_end'],
'supports_offset': True,
},
{
'label': 'Status Changes',
'triggers': ['on_complete', 'on_cancel'],
'supports_offset': False,
},
]
})
class GlobalEventPluginViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing global event plugin rules.
Global event plugins automatically attach to ALL events - both existing
events and new events as they are created.
Use this for automation rules that should apply across the board, such as:
- Sending confirmation emails for all appointments
- Logging all event completions
- Running cleanup after every event
Endpoints:
- GET /api/global-event-plugins/ - List all global rules
- POST /api/global-event-plugins/ - Create rule (auto-applies to existing events)
- GET /api/global-event-plugins/{id}/ - Get rule details
- PATCH /api/global-event-plugins/{id}/ - Update rule
- DELETE /api/global-event-plugins/{id}/ - Delete rule
- POST /api/global-event-plugins/{id}/toggle/ - Enable/disable rule
- POST /api/global-event-plugins/{id}/reapply/ - Reapply to all events
"""
queryset = GlobalEventPlugin.objects.select_related(
'plugin_installation',
'plugin_installation__template',
'created_by'
).all()
serializer_class = GlobalEventPluginSerializer
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated
def get_queryset(self):
"""Optionally filter by active status"""
queryset = super().get_queryset()
is_active = self.request.query_params.get('is_active')
if is_active is not None:
queryset = queryset.filter(is_active=is_active.lower() == 'true')
return queryset.order_by('execution_order', 'created_at')
def perform_create(self, serializer):
"""Set created_by on creation"""
user = self.request.user if self.request.user.is_authenticated else None
serializer.save(created_by=user)
@action(detail=True, methods=['post'])
def toggle(self, request, pk=None):
"""Toggle is_active status of a global event plugin rule"""
global_plugin = self.get_object()
global_plugin.is_active = not global_plugin.is_active
global_plugin.save(update_fields=['is_active', 'updated_at'])
serializer = self.get_serializer(global_plugin)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def reapply(self, request, pk=None):
"""
Reapply this global rule to all events.
Useful if:
- Events were created while the rule was inactive
- Plugin attachments were manually removed
"""
global_plugin = self.get_object()
if not global_plugin.is_active:
return Response({
'error': 'Cannot reapply inactive rule. Enable it first.'
}, status=status.HTTP_400_BAD_REQUEST)
count = global_plugin.apply_to_all_events()
return Response({
'message': f'Applied to {count} events',
'events_affected': count
})
@action(detail=False, methods=['get'])
def triggers(self, request):
"""
Get available trigger options for the UI.
Returns trigger choices with human-readable labels and
common offset presets (same as EventPlugin).
"""
return Response({
'triggers': [
{'value': choice[0], 'label': choice[1]}
for choice in EventPlugin.Trigger.choices
],
'offset_presets': [
{'value': 0, 'label': 'Immediately'},
{'value': 5, 'label': '5 minutes'},
{'value': 10, 'label': '10 minutes'},
{'value': 15, 'label': '15 minutes'},
{'value': 30, 'label': '30 minutes'},
{'value': 60, 'label': '1 hour'},
],
})