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:
@@ -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'},
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user