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

@@ -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'},
],
})