Files
smoothschedule/smoothschedule/schedule/views.py
poduck ea6b8fdadd feat: Add plugin marketplace backend infrastructure
Backend Features:
- Created PluginTemplate and PluginInstallation models
- Built complete REST API with marketplace, my plugins, install/uninstall endpoints
- Platform plugins supported (PLATFORM visibility, no whitelisting required)
- Template variable extraction and compilation
- Plugin approval workflow for marketplace publishing
- Rating and review system
- Update detection and version management
- Install count tracking

API Endpoints:
- GET /api/plugin-templates/ - Browse marketplace (view=marketplace/my_plugins/platform)
- POST /api/plugin-templates/ - Create new plugin
- POST /api/plugin-templates/{id}/install/ - Install plugin as ScheduledTask
- POST /api/plugin-templates/{id}/publish/ - Publish to marketplace
- POST /api/plugin-templates/{id}/approve/ - Approve for marketplace (admins)
- POST /api/plugin-installations/{id}/rate/ - Rate and review
- POST /api/plugin-installations/{id}/update_to_latest/ - Update plugin
- DELETE /api/plugin-installations/{id}/ - Uninstall plugin

Platform Plugins:
- Created seed_platform_plugins management command
- 6 starter plugins ready: daily summary, no-show tracker, birthdays, revenue reports, reminders, re-engagement
- Platform plugins bypass whitelist validation
- Pre-approved and available to all businesses

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 21:24:27 -05:00

1033 lines
36 KiB
Python

"""
Schedule App - DRF ViewSets
API endpoints for Resources and Events with quota enforcement.
"""
from rest_framework import viewsets, status
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 .serializers import (
ResourceSerializer, EventSerializer, ParticipantSerializer,
CustomerSerializer, ServiceSerializer, ResourceTypeSerializer, StaffSerializer,
ScheduledTaskSerializer, TaskExecutionLogSerializer, PluginInfoSerializer,
PluginTemplateSerializer, PluginTemplateListSerializer, PluginInstallationSerializer
)
from .models import Service
from core.permissions import HasQuota
from smoothschedule.users.models import User
class ResourceTypeViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing custom Resource Types.
Permissions:
- Must be authenticated
- Only owners/managers can create/update/delete
Functionality:
- List all resource types
- Create new custom types
- Update existing types (except is_default flag)
- Delete types (only if not default and not in use)
"""
queryset = ResourceType.objects.all()
serializer_class = ResourceTypeSerializer
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated for production
ordering = ['name']
def destroy(self, request, *args, **kwargs):
"""Override destroy to add validation"""
instance = self.get_object()
# Check if default
if instance.is_default:
return Response(
{'error': 'Cannot delete default resource types.'},
status=status.HTTP_400_BAD_REQUEST
)
# Check if in use
if instance.resources.exists():
return Response(
{
'error': f"Cannot delete resource type '{instance.name}' because it is in use by {instance.resources.count()} resource(s)."
},
status=status.HTTP_400_BAD_REQUEST
)
return super().destroy(request, *args, **kwargs)
class ResourceViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing Resources.
Permissions:
- Must be authenticated
- Subject to MAX_RESOURCES quota (hard block on creation)
The HasQuota permission prevents creating resources when tenant
has reached their subscription tier limit.
"""
queryset = Resource.objects.all()
serializer_class = ResourceSerializer
# TODO: Re-enable authentication for production
permission_classes = [AllowAny] # Temporarily allow unauthenticated access for development
filterset_fields = ['is_active', 'max_concurrent_events']
search_fields = ['name', 'description']
ordering_fields = ['name', 'created_at', 'max_concurrent_events']
ordering = ['name']
def perform_create(self, serializer):
"""Create resource (quota-checked by HasQuota permission)"""
serializer.save()
def perform_update(self, serializer):
"""Update resource"""
serializer.save()
class EventViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing Events.
Permissions:
- Must be authenticated
Validation:
- EventSerializer.validate() automatically checks resource availability
- If resource capacity exceeded, returns 400 Bad Request
- See schedule/services.py AvailabilityService for logic
Query Parameters:
- start_date: Filter events starting on or after this date (ISO format)
- end_date: Filter events starting before this date (ISO format)
- status: Filter by event status
"""
queryset = Event.objects.all()
serializer_class = EventSerializer
# TODO: Re-enable authentication for production
permission_classes = [AllowAny] # Temporarily allow unauthenticated access for development
filterset_fields = ['status']
search_fields = ['title', 'notes']
ordering_fields = ['start_time', 'end_time', 'created_at']
ordering = ['start_time']
def get_queryset(self):
"""
Filter events by date range if start_date and end_date are provided.
"""
queryset = Event.objects.all()
# Filter by date range
start_date = self.request.query_params.get('start_date')
end_date = self.request.query_params.get('end_date')
if start_date:
from django.utils.dateparse import parse_datetime
start_dt = parse_datetime(start_date)
if start_dt:
queryset = queryset.filter(start_time__gte=start_dt)
if end_date:
from django.utils.dateparse import parse_datetime
end_dt = parse_datetime(end_date)
if end_dt:
queryset = queryset.filter(start_time__lt=end_dt)
return queryset
def perform_create(self, serializer):
"""
Create event with automatic availability validation.
The EventSerializer.validate() method calls AvailabilityService
to check if resources have capacity. If not, DRF automatically
returns 400 Bad Request with error details.
"""
# TODO: Re-enable authentication - this is temporary for development
if self.request.user.is_authenticated:
serializer.save(created_by=self.request.user)
else:
serializer.save(created_by=None)
def perform_update(self, serializer):
"""
Update event with availability re-validation.
Uses exclude_event_id in AvailabilityService to allow
rescheduling of the event itself.
"""
serializer.save()
class ParticipantViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing Event Participants.
Allows adding/removing participants (Resources, Staff, Customers)
to/from events via the GenericForeignKey pattern.
"""
queryset = Participant.objects.all()
serializer_class = ParticipantSerializer
permission_classes = [IsAuthenticated]
filterset_fields = ['event', 'role', 'content_type']
ordering_fields = ['created_at']
ordering = ['-created_at']
class CustomerViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing Customers.
Customers are Users with role=CUSTOMER belonging to the current tenant.
"""
serializer_class = CustomerSerializer
# TODO: Re-enable authentication for production
permission_classes = [AllowAny] # Temporarily allow unauthenticated access for development
filterset_fields = ['is_active']
search_fields = ['email', 'first_name', 'last_name']
ordering_fields = ['email', 'created_at']
ordering = ['email']
def get_queryset(self):
"""
Return customers for the current tenant, filtered by sandbox mode.
Customers are Users with role=CUSTOMER.
In sandbox mode, only returns customers with is_sandbox=True.
In live mode, only returns customers with is_sandbox=False.
"""
queryset = User.objects.filter(role=User.Role.CUSTOMER)
# Filter by tenant if user is authenticated and has a tenant
if self.request.user.is_authenticated and self.request.user.tenant:
queryset = queryset.filter(tenant=self.request.user.tenant)
# Filter by sandbox mode - check request.sandbox_mode set by middleware
is_sandbox = getattr(self.request, 'sandbox_mode', False)
queryset = queryset.filter(is_sandbox=is_sandbox)
# Apply status filter if provided
status_filter = self.request.query_params.get('status')
if status_filter:
if status_filter == 'Active':
queryset = queryset.filter(is_active=True)
elif status_filter == 'Inactive':
queryset = queryset.filter(is_active=False)
# Apply search filter if provided
search = self.request.query_params.get('search')
if search:
from django.db.models import Q
queryset = queryset.filter(
Q(email__icontains=search) |
Q(first_name__icontains=search) |
Q(last_name__icontains=search)
)
return queryset
def perform_create(self, serializer):
"""
Set sandbox mode and tenant when creating a new customer.
"""
is_sandbox = getattr(self.request, 'sandbox_mode', False)
tenant = None
if self.request.user.is_authenticated and self.request.user.tenant:
tenant = self.request.user.tenant
serializer.save(
role=User.Role.CUSTOMER,
is_sandbox=is_sandbox,
tenant=tenant,
)
class ServiceViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing Services.
Services are the offerings a business provides (e.g., Haircut, Massage).
"""
queryset = Service.objects.filter(is_active=True)
serializer_class = ServiceSerializer
# TODO: Re-enable authentication for production
permission_classes = [AllowAny] # Temporarily allow unauthenticated access for development
filterset_fields = ['is_active']
search_fields = ['name', 'description']
ordering_fields = ['name', 'price', 'duration', 'display_order', 'created_at']
ordering = ['display_order', 'name']
def get_queryset(self):
"""Return services, optionally including inactive ones."""
queryset = Service.objects.all()
# By default only show active services
show_inactive = self.request.query_params.get('show_inactive', 'false')
if show_inactive.lower() != 'true':
queryset = queryset.filter(is_active=True)
return queryset
@action(detail=False, methods=['post'])
def reorder(self, request):
"""
Bulk update service display order.
Expects: { "order": [1, 3, 2, 5, 4] }
Where the list contains service IDs in the desired display order.
"""
order = request.data.get('order', [])
if not isinstance(order, list):
return Response(
{'error': 'order must be a list of service IDs'},
status=status.HTTP_400_BAD_REQUEST
)
# Update display_order for each service
for index, service_id in enumerate(order):
Service.objects.filter(id=service_id).update(display_order=index)
return Response({'status': 'ok', 'updated': len(order)})
class StaffViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing staff members (Users who can be assigned to resources).
Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
Supports:
- GET /api/staff/ - List staff members
- GET /api/staff/{id}/ - Get staff member details
- PATCH /api/staff/{id}/ - Update staff member (is_active, permissions)
- POST /api/staff/{id}/toggle_active/ - Toggle active status
"""
serializer_class = StaffSerializer
# TODO: Re-enable authentication for production
permission_classes = [AllowAny]
search_fields = ['email', 'first_name', 'last_name']
ordering_fields = ['email', 'first_name', 'last_name']
ordering = ['first_name', 'last_name']
# Disable create and delete - staff are managed via invitations
# Note: 'post' is needed for custom actions like toggle_active
http_method_names = ['get', 'patch', 'post', 'head', 'options']
def get_queryset(self):
"""
Return staff members for the current tenant, filtered by sandbox mode.
Staff are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
In sandbox mode, only returns staff with is_sandbox=True.
In live mode, only returns staff with is_sandbox=False.
"""
from django.db.models import Q
# Include inactive staff for listing (so admins can reactivate them)
show_inactive = self.request.query_params.get('show_inactive', 'true')
queryset = User.objects.filter(
Q(role=User.Role.TENANT_OWNER) |
Q(role=User.Role.TENANT_MANAGER) |
Q(role=User.Role.TENANT_STAFF)
)
if show_inactive.lower() != 'true':
queryset = queryset.filter(is_active=True)
# Filter by tenant if user is authenticated and has a tenant
# TODO: Re-enable this when authentication is enabled
# if self.request.user.is_authenticated and self.request.user.tenant:
# queryset = queryset.filter(tenant=self.request.user.tenant)
# Filter by sandbox mode - check request.sandbox_mode set by middleware
is_sandbox = getattr(self.request, 'sandbox_mode', False)
queryset = queryset.filter(is_sandbox=is_sandbox)
# Apply search filter if provided
search = self.request.query_params.get('search')
if search:
queryset = queryset.filter(
Q(email__icontains=search) |
Q(first_name__icontains=search) |
Q(last_name__icontains=search)
)
return queryset
def partial_update(self, request, *args, **kwargs):
"""
Update staff member.
Allowed fields: is_active, permissions
Owners can edit any staff member.
Managers can only edit staff (not other managers or owners).
"""
instance = self.get_object()
# TODO: Add permission checks when authentication is enabled
# current_user = request.user
# if current_user.role == User.Role.TENANT_MANAGER:
# if instance.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
# return Response(
# {'error': 'Managers cannot edit owners or other managers.'},
# status=status.HTTP_403_FORBIDDEN
# )
# Only allow updating specific fields
allowed_fields = {'is_active', 'permissions'}
update_data = {k: v for k, v in request.data.items() if k in allowed_fields}
serializer = self.get_serializer(instance, data=update_data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@action(detail=True, methods=['post'])
def toggle_active(self, request, pk=None):
"""Toggle the active status of a staff member."""
staff = self.get_object()
# Prevent deactivating yourself
# TODO: Enable this check when authentication is enabled
# if request.user.id == staff.id:
# return Response(
# {'error': 'You cannot deactivate your own account.'},
# status=status.HTTP_400_BAD_REQUEST
# )
staff.is_active = not staff.is_active
staff.save(update_fields=['is_active'])
return Response({
'id': staff.id,
'is_active': staff.is_active,
'message': f"Staff member {'activated' if staff.is_active else 'deactivated'} successfully."
})
class ScheduledTaskViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing scheduled tasks.
Permissions:
- Must be authenticated
- Only owners/managers can create/update/delete
Features:
- List all scheduled tasks
- Create new scheduled tasks
- Update existing tasks
- Delete tasks
- Pause/resume tasks
- Trigger manual execution
- View execution logs
"""
queryset = ScheduledTask.objects.all()
serializer_class = ScheduledTaskSerializer
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated for production
ordering = ['-created_at']
def perform_create(self, serializer):
"""Set created_by to current user"""
# TODO: Uncomment when auth is enabled
# serializer.save(created_by=self.request.user)
serializer.save()
@action(detail=True, methods=['post'])
def pause(self, request, pk=None):
"""Pause a scheduled task"""
task = self.get_object()
if task.status == ScheduledTask.Status.PAUSED:
return Response(
{'error': 'Task is already paused'},
status=status.HTTP_400_BAD_REQUEST
)
task.status = ScheduledTask.Status.PAUSED
task.save(update_fields=['status'])
return Response({
'id': task.id,
'status': task.status,
'message': 'Task paused successfully'
})
@action(detail=True, methods=['post'])
def resume(self, request, pk=None):
"""Resume a paused scheduled task"""
task = self.get_object()
if task.status != ScheduledTask.Status.PAUSED:
return Response(
{'error': 'Task is not paused'},
status=status.HTTP_400_BAD_REQUEST
)
task.status = ScheduledTask.Status.ACTIVE
task.update_next_run_time()
task.save(update_fields=['status'])
return Response({
'id': task.id,
'status': task.status,
'next_run_at': task.next_run_at,
'message': 'Task resumed successfully'
})
@action(detail=True, methods=['post'])
def execute(self, request, pk=None):
"""Manually trigger task execution"""
task = self.get_object()
# Import here to avoid circular dependency
from .tasks import execute_scheduled_task
# Queue the task for immediate execution
result = execute_scheduled_task.delay(task.id)
return Response({
'id': task.id,
'celery_task_id': result.id,
'message': 'Task queued for execution'
})
@action(detail=True, methods=['get'])
def logs(self, request, pk=None):
"""Get execution logs for this task"""
task = self.get_object()
# Get pagination parameters
limit = int(request.query_params.get('limit', 20))
offset = int(request.query_params.get('offset', 0))
logs = task.execution_logs.all()[offset:offset + limit]
serializer = TaskExecutionLogSerializer(logs, many=True)
return Response({
'count': task.execution_logs.count(),
'results': serializer.data
})
class TaskExecutionLogViewSet(viewsets.ReadOnlyModelViewSet):
"""
API endpoint for viewing task execution logs (read-only).
Features:
- List all execution logs
- Filter by task, status, date range
- View individual log details
"""
queryset = TaskExecutionLog.objects.select_related('scheduled_task').all()
serializer_class = TaskExecutionLogSerializer
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated for production
ordering = ['-started_at']
def get_queryset(self):
"""Filter logs by query parameters"""
queryset = super().get_queryset()
# Filter by scheduled task
task_id = self.request.query_params.get('task_id')
if task_id:
queryset = queryset.filter(scheduled_task_id=task_id)
# Filter by status
status_filter = self.request.query_params.get('status')
if status_filter:
queryset = queryset.filter(status=status_filter)
return queryset
class PluginViewSet(viewsets.ViewSet):
"""
API endpoint for listing available plugins.
Features:
- List all registered plugins
- Get plugin details
- List plugins by category
"""
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated for production
def list(self, request):
"""List all available plugins"""
from .plugins import registry
plugins = registry.list_all()
serializer = PluginInfoSerializer(plugins, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def by_category(self, request):
"""List plugins grouped by category"""
from .plugins import registry
plugins_by_category = registry.list_by_category()
return Response(plugins_by_category)
def retrieve(self, request, pk=None):
"""Get details for a specific plugin"""
from .plugins import registry
plugin_class = registry.get(pk)
if not plugin_class:
return Response(
{'error': f"Plugin '{pk}' not found"},
status=status.HTTP_404_NOT_FOUND
)
plugin_info = {
'name': plugin_class.name,
'display_name': plugin_class.display_name,
'description': plugin_class.description,
'category': plugin_class.category,
'config_schema': plugin_class.config_schema,
}
serializer = PluginInfoSerializer(plugin_info)
return Response(serializer.data)
class PluginTemplateViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing plugin templates.
Features:
- List all plugin templates (filtered by visibility)
- Create new plugin templates
- Update existing templates
- Delete templates
- Publish to marketplace
- Unpublish from marketplace
- Install a template as a ScheduledTask
- Request approval (for marketplace publishing)
- Approve/reject templates (platform admins only)
"""
queryset = PluginTemplate.objects.all()
serializer_class = PluginTemplateSerializer
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated for production
ordering = ['-created_at']
filterset_fields = ['visibility', 'category', 'is_approved']
search_fields = ['name', 'short_description', 'description', 'tags']
def get_queryset(self):
"""
Filter templates based on user permissions.
- Marketplace view: Only approved PUBLIC templates
- My Plugins: User's own templates (all visibilities)
- Platform admins: All templates
"""
queryset = super().get_queryset()
view_mode = self.request.query_params.get('view', 'marketplace')
if view_mode == 'marketplace':
# Public marketplace - only approved public templates
queryset = queryset.filter(
visibility=PluginTemplate.Visibility.PUBLIC,
is_approved=True
)
elif view_mode == 'my_plugins':
# User's own templates
if self.request.user.is_authenticated:
queryset = queryset.filter(author=self.request.user)
else:
queryset = queryset.none()
elif view_mode == 'platform':
# Platform official plugins
queryset = queryset.filter(visibility=PluginTemplate.Visibility.PLATFORM)
# else: all templates (for platform admins)
# Filter by category if provided
category = self.request.query_params.get('category')
if category:
queryset = queryset.filter(category=category)
# Filter by search query
search = self.request.query_params.get('search')
if search:
from django.db.models import Q
queryset = queryset.filter(
Q(name__icontains=search) |
Q(short_description__icontains=search) |
Q(description__icontains=search) |
Q(tags__icontains=search)
)
return queryset
def get_serializer_class(self):
"""Use lightweight serializer for list view"""
if self.action == 'list':
return PluginTemplateListSerializer
return PluginTemplateSerializer
def perform_create(self, serializer):
"""Set author and extract template variables on create"""
from .template_parser import TemplateVariableParser
plugin_code = serializer.validated_data.get('plugin_code', '')
template_vars = TemplateVariableParser.extract_variables(plugin_code)
# Convert to dict format expected by model
template_vars_dict = {var['name']: var for var in template_vars}
serializer.save(
author=self.request.user if self.request.user.is_authenticated else None,
template_variables=template_vars_dict
)
@action(detail=True, methods=['post'])
def publish(self, request, pk=None):
"""Publish template to marketplace (requires approval)"""
template = self.get_object()
# Check ownership
if template.author != request.user:
return Response(
{'error': 'You can only publish your own templates'},
status=status.HTTP_403_FORBIDDEN
)
# Check if approved
if not template.is_approved:
return Response(
{'error': 'Template must be approved before publishing to marketplace'},
status=status.HTTP_400_BAD_REQUEST
)
# Publish
try:
template.publish_to_marketplace(request.user)
return Response({
'message': 'Template published to marketplace successfully',
'slug': template.slug
})
except DjangoValidationError as e:
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['post'])
def unpublish(self, request, pk=None):
"""Unpublish template from marketplace"""
template = self.get_object()
# Check ownership
if template.author != request.user:
return Response(
{'error': 'You can only unpublish your own templates'},
status=status.HTTP_403_FORBIDDEN
)
template.unpublish_from_marketplace()
return Response({
'message': 'Template unpublished from marketplace successfully'
})
@action(detail=True, methods=['post'])
def install(self, request, pk=None):
"""
Install a plugin template as a ScheduledTask.
Expects:
{
"name": "Task Name",
"description": "Task Description",
"config_values": {"variable1": "value1", ...},
"schedule_type": "CRON",
"cron_expression": "0 0 * * *"
}
"""
template = self.get_object()
# Check if template is accessible
if template.visibility == PluginTemplate.Visibility.PRIVATE:
if not request.user.is_authenticated or template.author != request.user:
return Response(
{'error': 'This template is private'},
status=status.HTTP_403_FORBIDDEN
)
elif template.visibility == PluginTemplate.Visibility.PUBLIC:
if not template.is_approved:
return Response(
{'error': 'This template has not been approved'},
status=status.HTTP_400_BAD_REQUEST
)
# Create ScheduledTask from template
from .template_parser import TemplateVariableParser
name = request.data.get('name')
description = request.data.get('description', '')
config_values = request.data.get('config_values', {})
schedule_type = request.data.get('schedule_type')
cron_expression = request.data.get('cron_expression')
interval_minutes = request.data.get('interval_minutes')
run_at = request.data.get('run_at')
if not name:
return Response(
{'error': 'name is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Compile template with config values
try:
compiled_code = TemplateVariableParser.compile_template(
template.plugin_code,
config_values,
context={} # TODO: Add business context
)
except ValueError as e:
return Response(
{'error': f'Configuration error: {str(e)}'},
status=status.HTTP_400_BAD_REQUEST
)
# Create ScheduledTask
scheduled_task = ScheduledTask.objects.create(
name=name,
description=description,
plugin_name='custom_script', # Use custom script plugin
plugin_code=compiled_code,
plugin_config={},
schedule_type=schedule_type,
cron_expression=cron_expression,
interval_minutes=interval_minutes,
run_at=run_at,
status=ScheduledTask.Status.ACTIVE,
created_by=request.user if request.user.is_authenticated else None
)
# Create PluginInstallation record
installation = PluginInstallation.objects.create(
template=template,
scheduled_task=scheduled_task,
installed_by=request.user if request.user.is_authenticated else None,
config_values=config_values,
template_version_hash=template.plugin_code_hash
)
# Increment install count
template.install_count += 1
template.save(update_fields=['install_count'])
return Response({
'message': 'Plugin installed successfully',
'scheduled_task_id': scheduled_task.id,
'installation_id': installation.id
}, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post'])
def request_approval(self, request, pk=None):
"""Request approval for marketplace publishing"""
template = self.get_object()
# Check ownership
if template.author != request.user:
return Response(
{'error': 'You can only request approval for your own templates'},
status=status.HTTP_403_FORBIDDEN
)
# Check if already approved or pending
if template.is_approved:
return Response(
{'error': 'Template is already approved'},
status=status.HTTP_400_BAD_REQUEST
)
# Validate plugin code
validation = template.can_be_published()
if not validation:
from .safe_scripting import validate_plugin_whitelist
errors = validate_plugin_whitelist(template.plugin_code)
return Response(
{'error': 'Template has validation errors', 'errors': errors['errors']},
status=status.HTTP_400_BAD_REQUEST
)
# TODO: Notify platform admins about approval request
# For now, just return success
return Response({
'message': 'Approval requested successfully. A platform administrator will review your plugin.',
'template_id': template.id
})
@action(detail=True, methods=['post'])
def approve(self, request, pk=None):
"""Approve template for marketplace (platform admins only)"""
# TODO: Add permission check for platform admins
# if not request.user.has_perm('can_approve_plugins'):
# return Response({'error': 'Permission denied'}, status=status.HTTP_403_FORBIDDEN)
template = self.get_object()
if template.is_approved:
return Response(
{'error': 'Template is already approved'},
status=status.HTTP_400_BAD_REQUEST
)
# Validate plugin code
from .safe_scripting import validate_plugin_whitelist
validation = validate_plugin_whitelist(template.plugin_code, scheduled_task=None)
if not validation['valid']:
return Response(
{'error': 'Template has validation errors', 'errors': validation['errors']},
status=status.HTTP_400_BAD_REQUEST
)
# Approve
from django.utils import timezone
template.is_approved = True
template.approved_by = request.user if request.user.is_authenticated else None
template.approved_at = timezone.now()
template.rejection_reason = ''
template.save()
return Response({
'message': 'Template approved successfully',
'template_id': template.id
})
@action(detail=True, methods=['post'])
def reject(self, request, pk=None):
"""Reject template for marketplace (platform admins only)"""
# TODO: Add permission check for platform admins
# if not request.user.has_perm('can_approve_plugins'):
# return Response({'error': 'Permission denied'}, status=status.HTTP_403_FORBIDDEN)
template = self.get_object()
reason = request.data.get('reason', 'No reason provided')
template.is_approved = False
template.rejection_reason = reason
template.save()
return Response({
'message': 'Template rejected',
'reason': reason
})
class PluginInstallationViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing plugin installations.
Features:
- List user's installed plugins
- View installation details
- Update installation (update to latest version)
- Uninstall plugin
- Rate and review plugin
"""
queryset = PluginInstallation.objects.select_related('template', 'scheduled_task').all()
serializer_class = PluginInstallationSerializer
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated for production
ordering = ['-installed_at']
def get_queryset(self):
"""Return installations for current user/tenant"""
queryset = super().get_queryset()
# TODO: Filter by tenant when multi-tenancy is fully enabled
# if self.request.user.is_authenticated and self.request.user.tenant:
# queryset = queryset.filter(scheduled_task__tenant=self.request.user.tenant)
return queryset
@action(detail=True, methods=['post'])
def update_to_latest(self, request, pk=None):
"""Update installed plugin to latest template version"""
installation = self.get_object()
if not installation.has_update_available():
return Response(
{'error': 'No update available'},
status=status.HTTP_400_BAD_REQUEST
)
try:
installation.update_to_latest()
return Response({
'message': 'Plugin updated successfully',
'new_version_hash': installation.template_version_hash
})
except DjangoValidationError as e:
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['post'])
def rate(self, request, pk=None):
"""Rate an installed plugin"""
installation = self.get_object()
rating = request.data.get('rating')
review = request.data.get('review', '')
if not rating or not isinstance(rating, int) or rating < 1 or rating > 5:
return Response(
{'error': 'Rating must be an integer between 1 and 5'},
status=status.HTTP_400_BAD_REQUEST
)
# Update installation
from django.utils import timezone
installation.rating = rating
installation.review = review
installation.reviewed_at = timezone.now()
installation.save()
# Update template average rating
if installation.template:
template = installation.template
ratings = PluginInstallation.objects.filter(
template=template,
rating__isnull=False
).values_list('rating', flat=True)
if ratings:
from decimal import Decimal
template.rating_average = Decimal(sum(ratings)) / Decimal(len(ratings))
template.rating_count = len(ratings)
template.save(update_fields=['rating_average', 'rating_count'])
return Response({
'message': 'Rating submitted successfully',
'rating': rating
})
def destroy(self, request, *args, **kwargs):
"""Uninstall plugin (delete ScheduledTask and Installation)"""
installation = self.get_object()
# Delete the scheduled task (this will cascade delete the installation)
if installation.scheduled_task:
installation.scheduled_task.delete()
else:
# If scheduled task was already deleted, just delete the installation
installation.delete()
return Response({
'message': 'Plugin uninstalled successfully'
}, status=status.HTTP_204_NO_CONTENT)