diff --git a/frontend/src/components/CreateTaskModal.tsx b/frontend/src/components/CreateTaskModal.tsx new file mode 100644 index 0000000..c961f74 --- /dev/null +++ b/frontend/src/components/CreateTaskModal.tsx @@ -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; + scheduled_task?: number; + scheduled_task_name?: string; + installed_at: string; + config_values: Record; + 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 = ({ isOpen, onClose, onSuccess }) => { + const queryClient = useQueryClient(); + const [step, setStep] = useState(1); + const [selectedPlugin, setSelectedPlugin] = useState(null); + const [taskName, setTaskName] = useState(''); + const [description, setDescription] = useState(''); + + // Task type selection + const [taskType, setTaskType] = useState('scheduled'); + + // Schedule selection (for scheduled tasks) + const [selectedPreset, setSelectedPreset] = useState('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('at_start'); + const [selectedOffset, setSelectedOffset] = useState(0); + const [applyToExisting, setApplyToExisting] = useState(true); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(''); + + // Fetch available plugins + const { data: plugins = [], isLoading: pluginsLoading } = useQuery({ + 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 ( +
+
+ {/* Header */} +
+

+ Schedule a Task +

+ +
+ + {/* Steps indicator */} +
+
+
= 1 ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}> +
= 1 ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'}`}> + 1 +
+ Select Plugin +
+
+
= 2 ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}> +
= 2 ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'}`}> + 2 +
+ Configure +
+
+
+ + {/* Content */} +
+ {/* Step 1: Select Plugin */} + {step === 1 && ( +
+

+ Select a plugin to schedule for automatic execution. +

+ + {pluginsLoading ? ( +
+
+
+ ) : plugins.length === 0 ? ( +
+ +

No available plugins

+

+ Install plugins from the Marketplace first, or all your plugins are already scheduled. +

+
+ ) : ( +
+ {plugins.map((plugin) => ( + + ))} +
+ )} +
+ )} + + {/* Step 2: Configure Schedule */} + {step === 2 && selectedPlugin && ( +
+ {/* Selected Plugin */} +
+
+ +
+

+ Selected Plugin +

+

+ {selectedPlugin.template_name} +

+
+
+
+ + {/* Task Type Selection */} +
+ +
+ + +
+
+ + {/* Scheduled Task Options */} + {taskType === 'scheduled' && ( + <> + {/* Task Name */} +
+ + 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" + /> +
+ + {/* Schedule Mode Tabs */} +
+ +
+ + + +
+ + {/* Preset Selection */} + {scheduleMode === 'preset' && ( +
+ {SCHEDULE_PRESETS.map((preset) => ( + + ))} +
+ )} + + {/* One-Time Selection */} + {scheduleMode === 'onetime' && ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ )} + + {/* Advanced Cron */} + {scheduleMode === 'advanced' && ( +
+
+ + 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 * * *" + /> +

+ Format: minute hour day month weekday (e.g., "0 9 * * 1-5" = weekdays at 9 AM) +

+
+
+ )} +
+ + {/* Schedule Preview */} +
+
+ + + Schedule: {getScheduleDescription()} + +
+
+ + )} + + {/* Event Automation Options */} + {taskType === 'event' && ( + <> + {/* Apply to which events */} +
+ +
+ + +
+
+ + {/* Info about timing updates */} +
+

+ If an event is rescheduled, the plugin timing will automatically update. +

+
+ + {/* When to run */} +
+ +
+ {TRIGGER_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Offset selection (only for time-based triggers) */} + {showOffset && ( +
+ +
+ {OFFSET_PRESETS.map((preset) => ( + + ))} +
+
+ )} + + {/* Event Timing Preview */} +
+
+ + + Runs: {getEventTimingDescription()} + +
+
+ + )} + + {/* Error */} + {error && ( +
+

{error}

+
+ )} +
+ )} +
+ + {/* Footer */} +
+ {step === 2 && ( + + )} +
+ + {step === 2 && ( + + )} +
+
+
+ ); +}; + +export default CreateTaskModal; diff --git a/frontend/src/components/EditTaskModal.tsx b/frontend/src/components/EditTaskModal.tsx new file mode 100644 index 0000000..619e779 --- /dev/null +++ b/frontend/src/components/EditTaskModal.tsx @@ -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; + 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 = ({ task, isOpen, onClose, onSuccess }) => { + const [taskName, setTaskName] = useState(''); + const [description, setDescription] = useState(''); + const [selectedPreset, setSelectedPreset] = useState(''); + 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 ( +
+
+ {/* Header */} +
+

+ Edit Task +

+ +
+ + {/* Content */} +
+ {/* Plugin Info (read-only) */} +
+
+ +
+

+ Plugin +

+

+ {task.plugin_display_name} +

+
+
+
+ + {/* Task Name */} +
+ + 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" + /> +
+ + {/* Schedule Mode Tabs */} +
+ +
+ + + +
+ + {/* Preset Selection */} + {scheduleMode === 'preset' && ( +
+ {SCHEDULE_PRESETS.map((preset) => ( + + ))} +
+ )} + + {/* One-Time Selection */} + {scheduleMode === 'onetime' && ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ )} + + {/* Advanced Cron */} + {scheduleMode === 'advanced' && ( +
+
+ + 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 * * *" + /> +

+ Format: minute hour day month weekday (e.g., "0 9 * * 1-5" = weekdays at 9 AM) +

+
+
+ )} +
+ + {/* Schedule Preview */} +
+
+ + + Schedule: {getScheduleDescription()} + +
+
+ + {/* Error */} + {error && ( +
+

{error}

+
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +export default EditTaskModal; diff --git a/frontend/src/components/EventAutomations.tsx b/frontend/src/components/EventAutomations.tsx new file mode 100644 index 0000000..b1bf976 --- /dev/null +++ b/frontend/src/components/EventAutomations.tsx @@ -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 = ({ eventId, compact = false }) => { + const queryClient = useQueryClient(); + const [showAddForm, setShowAddForm] = useState(false); + const [selectedPlugin, setSelectedPlugin] = useState(''); + const [selectedTrigger, setSelectedTrigger] = useState('at_start'); + const [selectedOffset, setSelectedOffset] = useState(0); + + // Fetch installed plugins + const { data: plugins = [] } = useQuery({ + queryKey: ['plugin-installations'], + queryFn: async () => { + const { data } = await axios.get('/api/plugin-installations/'); + return data; + }, + }); + + // Fetch event plugins + const { data: eventPlugins = [], isLoading } = useQuery({ + 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 ( +
+ Loading automations... +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + Automations + + {eventPlugins.length > 0 && ( + + {eventPlugins.length} + + )} +
+ {!showAddForm && plugins.length > 0 && ( + + )} +
+ + {/* Existing automations */} + {eventPlugins.length > 0 && ( +
+ {eventPlugins.map((ep) => ( +
+
+
+ +
+
+

+ {ep.plugin_name} +

+
+ + {ep.timing_description} +
+
+
+ +
+ + +
+
+ ))} +
+ )} + + {/* Add automation form */} + {showAddForm && ( +
+ {/* Plugin selector */} +
+ + +
+ + {/* Timing selector - visual */} +
+ +
+ {TRIGGER_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Offset selector - only for time-based triggers */} + {showOffset && ( +
+ +
+ {OFFSET_PRESETS.map((preset) => ( + + ))} +
+
+ )} + + {/* Preview */} + {selectedPlugin && ( +
+ Will run: + {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'} + +
+ )} + + {/* Actions */} +
+ + +
+
+ )} + + {/* Empty state */} + {eventPlugins.length === 0 && !showAddForm && ( +
+ {plugins.length > 0 ? ( + + ) : ( +

+ Install plugins from the Marketplace to use automations +

+ )} +
+ )} +
+ ); +}; + +export default EventAutomations; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index dd242ba..6443a37 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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 = ({ 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 = ({ business, user, isCollapsed, toggleCo {!isCollapsed && {t('nav.scheduler')}} + + + {!isCollapsed && {t('nav.tasks', 'Tasks')}} + + {canViewManagementPages && ( <> @@ -203,6 +210,14 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo {isPluginsOpen && !isCollapsed && (
+ + + {t('nav.marketplace', 'Marketplace')} + { {/* Actions */}
+ {/* Schedule button - only if not already scheduled */} + {!plugin.scheduledTaskId && ( + + )} + {/* Already scheduled indicator */} + {plugin.scheduledTaskId && ( + + + {t('plugins.scheduled', 'Scheduled')} + + )} {plugin.hasUpdate && (
)} + {/* Automations - only show for saved appointments */} + {selectedAppointment.id && ( +
+ +
+ )} + {/* Action Buttons */}
{plugin.isVerified && ( @@ -550,24 +579,123 @@ 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')} + {installedTemplateIds.has(selectedPlugin.id) ? ( + + ) : ( + + )} +
+
+
+ )} + + {/* What's Next Modal - shown after successful install */} + {showWhatsNextModal && selectedPlugin && ( +
+
+ {/* Success Header */} +
+
+ +
+

+ Plugin Installed! +

+

+ {selectedPlugin.name} is ready to use +

+
+ + {/* What's Next Options */} +
+

+ What would you like to do? +

+ + {/* Schedule Task Option */} + + {/* Configure Option */} + + + {/* Done Option */} +
diff --git a/frontend/src/pages/Tasks.tsx b/frontend/src/pages/Tasks.tsx index 995f64d..ae59c1c 100644 --- a/frontend/src/pages/Tasks.tsx +++ b/frontend/src/pages/Tasks.tsx @@ -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; 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; + scheduled_task?: number; + scheduled_task_name?: string; + installed_at: string; + config_values: Record; + 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(null); + const [editingEventAutomation, setEditingEventAutomation] = useState(null); - // Fetch tasks - const { data: tasks = [], isLoading } = useQuery({ + // Fetch scheduled tasks + const { data: scheduledTasks = [], isLoading: tasksLoading } = useQuery({ 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({ + 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 }) => { + 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 = () => {

Total Tasks

-

{tasks.length}

+

{allTasks.length}

@@ -179,21 +297,7 @@ const Tasks: React.FC = () => {

Active

- {tasks.filter(t => t.is_active).length} -

-
- - - -
-
-
- -
-
-

Paused

-

- {tasks.filter(t => !t.is_active).length} + {scheduledTasks.filter(t => t.status === 'ACTIVE').length + eventAutomations.filter(e => e.is_active).length}

@@ -202,12 +306,26 @@ const Tasks: React.FC = () => {
- +
-

Recurring

+

Event Automations

- {tasks.filter(t => t.schedule_type !== 'ONE_TIME').length} + {eventAutomations.length} +

+
+
+
+ +
+
+
+ +
+
+

Scheduled

+

+ {scheduledTasks.length}

@@ -215,7 +333,7 @@ const Tasks: React.FC = () => {
{/* Tasks List */} - {tasks.length === 0 ? ( + {allTasks.length === 0 ? (

@@ -234,132 +352,426 @@ const Tasks: React.FC = () => {

) : (
- {tasks.map((task) => { - const ScheduleIcon = getScheduleIcon(task); - const StatusIcon = getStatusIcon(task); + {allTasks.map((unifiedTask) => { + if (unifiedTask.type === 'scheduled') { + const task = unifiedTask.data; + const ScheduleIcon = getScheduleIcon(task); + const StatusIcon = getStatusIcon(task); - return ( -
-
-
-
-

- {task.name} -

- -
- - {task.description && ( -

- {task.description} -

- )} - -
-
- - {task.plugin_display_name} + return ( +
+
+
+
+ + Scheduled + +

+ {task.name} +

+
-
- - {getScheduleDisplay(task)} + {task.description && ( +

+ {task.description} +

+ )} + +
+
+ + {task.plugin_display_name} +
+ +
+ + {getScheduleDisplay(task)} +
+ + {task.next_run_at && task.status === 'ACTIVE' && ( +
+ + Next: {new Date(task.next_run_at).toLocaleString()} +
+ )} + + {task.last_run_at && ( +
+ + Last: {new Date(task.last_run_at).toLocaleString()} +
+ )}
- - {task.next_run_time && task.is_active && ( -
- - Next: {new Date(task.next_run_time).toLocaleString()} -
- )} - - {task.last_run_time && ( -
- - Last: {new Date(task.last_run_time).toLocaleString()} -
- )}
-
- {/* Actions */} -
- + {/* Actions */} +
+ - + - + - + +
-
- ); + ); + } else { + // Event automation + const automation = unifiedTask.data; + + return ( +
+
+
+
+ + Event Automation + +

+ {automation.plugin_name} +

+ {automation.is_active ? ( + + ) : ( + + )} +
+ + {automation.plugin_description && ( +

+ {automation.plugin_description} +

+ )} + +
+
+ + {automation.timing_description} +
+ +
+ + {automation.events_count} events +
+ +
+ + {automation.apply_to_existing ? 'All events' : 'Future only'} + +
+
+
+ + {/* Actions */} +
+ + + + + +
+
+
+ ); + } })}
)} - {/* TODO: Create/Edit Task Modal */} - {showCreateModal && ( -
-
-

- Create New Task -

-

- Task creation form coming soon... -

-
- -
-
-
+ {/* Create Task Modal */} + setShowCreateModal(false)} + onSuccess={() => { + queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] }); + queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] }); + }} + /> + + {/* Edit Task Modal */} + setEditingTask(null)} + onSuccess={() => { + queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] }); + toast.success('Task updated successfully'); + }} + /> + + {/* Edit Event Automation Modal */} + {editingEventAutomation && ( + setEditingEventAutomation(null)} + onSave={(data) => updateEventAutomationMutation.mutate({ id: editingEventAutomation.id, data })} + isLoading={updateEventAutomationMutation.isPending} + /> )}
); }; +// Inline Edit Event Automation Modal component +interface EditEventAutomationModalProps { + automation: GlobalEventPlugin; + isOpen: boolean; + onClose: () => void; + onSave: (data: Partial) => 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 = ({ + 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 ( +
+
+ {/* Header */} +
+

+ Edit Event Automation +

+ +
+ + {/* Content */} +
+ {/* Plugin info */} +
+
+ +
+

+ Plugin +

+

+ {automation.plugin_name} +

+
+
+
+ + {/* When to run */} +
+ +
+ {TRIGGER_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Offset selection */} + {showOffset && ( +
+ +
+ {OFFSET_PRESETS.map((preset) => ( + + ))} +
+
+ )} + + {/* Preview */} +
+
+ + + Runs: {getTimingDescription()} + +
+
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + export default Tasks; diff --git a/smoothschedule/schedule/apps.py b/smoothschedule/schedule/apps.py index ae4bab6..6701dce 100644 --- a/smoothschedule/schedule/apps.py +++ b/smoothschedule/schedule/apps.py @@ -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}") diff --git a/smoothschedule/schedule/migrations/0020_add_eventplugin_offset.py b/smoothschedule/schedule/migrations/0020_add_eventplugin_offset.py new file mode 100644 index 0000000..a24d90d --- /dev/null +++ b/smoothschedule/schedule/migrations/0020_add_eventplugin_offset.py @@ -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')}, + ), + ] diff --git a/smoothschedule/schedule/migrations/0021_add_global_event_plugin.py b/smoothschedule/schedule/migrations/0021_add_global_event_plugin.py new file mode 100644 index 0000000..fc8b213 --- /dev/null +++ b/smoothschedule/schedule/migrations/0021_add_global_event_plugin.py @@ -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')}, + }, + ), + ] diff --git a/smoothschedule/schedule/migrations/0022_add_apply_to_existing_flag.py b/smoothschedule/schedule/migrations/0022_add_apply_to_existing_flag.py new file mode 100644 index 0000000..3abbc99 --- /dev/null +++ b/smoothschedule/schedule/migrations/0022_add_apply_to_existing_flag.py @@ -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'), + ), + ] diff --git a/smoothschedule/schedule/models.py b/smoothschedule/schedule/models.py index 07c3579..b012aa3 100644 --- a/smoothschedule/schedule/models.py +++ b/smoothschedule/schedule/models.py @@ -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): diff --git a/smoothschedule/schedule/serializers.py b/smoothschedule/schedule/serializers.py index cb7b63d..2a4d1c3 100644 --- a/smoothschedule/schedule/serializers.py +++ b/smoothschedule/schedule/serializers.py @@ -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) diff --git a/smoothschedule/schedule/signals.py b/smoothschedule/schedule/signals.py new file mode 100644 index 0000000..41a38dc --- /dev/null +++ b/smoothschedule/schedule/signals.py @@ -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" + ) diff --git a/smoothschedule/schedule/urls.py b/smoothschedule/schedule/urls.py index 12cf377..3547bcb 100644 --- a/smoothschedule/schedule/urls.py +++ b/smoothschedule/schedule/urls.py @@ -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 = [ diff --git a/smoothschedule/schedule/views.py b/smoothschedule/schedule/views.py index 4d21584..e5bb843 100644 --- a/smoothschedule/schedule/views.py +++ b/smoothschedule/schedule/views.py @@ -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'}, + ], + })