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>
This commit is contained in:
poduck
2025-11-28 21:24:27 -05:00
parent 3723b33cad
commit ea6b8fdadd
6 changed files with 1057 additions and 4 deletions

View File

@@ -0,0 +1,92 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from schedule.models import PluginTemplate
class Command(BaseCommand):
help = 'Seed platform-owned plugins into the database'
def handle(self, *args, **options):
plugins_data = [
{
'name': 'Daily Appointment Summary Email',
'slug': 'daily-appointment-summary',
'category': PluginTemplate.Category.EMAIL,
'short_description': 'Send daily email summary of appointments',
'plugin_code': 'Get today\'s appointments, format list, send email to {{PROMPT:staff_email|Staff email}}',
},
{
'name': 'No-Show Customer Tracker',
'slug': 'no-show-tracker',
'category': PluginTemplate.Category.REPORTS,
'short_description': 'Track customers who miss appointments',
'plugin_code': 'Get no-shows from last week, group by customer, send report to {{PROMPT:manager_email|Manager email}}',
},
{
'name': 'Birthday Greeting Campaign',
'slug': 'birthday-greetings',
'category': PluginTemplate.Category.CUSTOMER,
'short_description': 'Send birthday emails with offers',
'plugin_code': 'Check for birthdays today, send personalized emails with {{PROMPT:discount_code|Discount code}}',
},
{
'name': 'Monthly Revenue Report',
'slug': 'monthly-revenue-report',
'category': PluginTemplate.Category.REPORTS,
'short_description': 'Monthly business statistics',
'plugin_code': 'Get last month\'s appointments, calculate stats, email to {{PROMPT:owner_email|Owner email}}',
},
{
'name': 'Appointment Reminder (24hr)',
'slug': 'appointment-reminder-24hr',
'category': PluginTemplate.Category.BOOKING,
'short_description': 'Remind customers 24hrs before appointments',
'plugin_code': 'Get appointments 24hrs from now, send reminder emails with {{PROMPT:custom_message|Custom message}}',
},
{
'name': 'Inactive Customer Re-engagement',
'slug': 'inactive-customer-reengagement',
'category': PluginTemplate.Category.CUSTOMER,
'short_description': 'Email inactive customers with offers',
'plugin_code': 'Find customers inactive for {{PROMPT:inactive_days|Days inactive|60}} days, send comeback offer with {{PROMPT:discount_code|Discount code}}',
},
]
created_count = 0
skipped_count = 0
for plugin_data in plugins_data:
# Check if plugin already exists by slug
if PluginTemplate.objects.filter(slug=plugin_data['slug']).exists():
self.stdout.write(
self.style.WARNING(f"Skipping '{plugin_data['name']}' - already exists")
)
skipped_count += 1
continue
# Create the plugin
plugin = PluginTemplate.objects.create(
name=plugin_data['name'],
slug=plugin_data['slug'],
category=plugin_data['category'],
short_description=plugin_data['short_description'],
description=plugin_data['short_description'],
plugin_code=plugin_data['plugin_code'],
visibility=PluginTemplate.Visibility.PLATFORM,
is_approved=True,
approved_at=timezone.now(),
author_name='SmoothSchedule Platform',
license_type='PLATFORM',
)
self.stdout.write(
self.style.SUCCESS(f"Created plugin: '{plugin.name}'")
)
created_count += 1
# Summary
self.stdout.write(
self.style.SUCCESS(
f'\nSuccessfully created {created_count} plugin(s), {skipped_count} already existed.'
)
)

View File

@@ -0,0 +1,92 @@
# Generated by Django 5.2.8 on 2025-11-29 02:17
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0014_whitelistedurl'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='scheduledtask',
name='plugin_code',
field=models.TextField(blank=True, help_text='Custom plugin code (for custom scripts)'),
),
migrations.CreateModel(
name='PluginTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text="Plugin name (e.g., 'Win Back Inactive Customers')", max_length=200)),
('slug', models.SlugField(help_text='URL-friendly identifier', max_length=200, unique=True)),
('description', models.TextField(help_text='What this plugin does (markdown supported)')),
('short_description', models.CharField(help_text='One-line summary for marketplace listing', max_length=200)),
('plugin_code', models.TextField(help_text='The Python script code')),
('plugin_code_hash', models.CharField(blank=True, help_text='SHA-256 hash of code for verification', max_length=64)),
('template_variables', models.JSONField(blank=True, default=dict, help_text='Template variables extracted from code (PROMPT, CONTEXT, DATE)')),
('default_config', models.JSONField(blank=True, default=dict, help_text='Default configuration values')),
('visibility', models.CharField(choices=[('PRIVATE', 'Private (only me)'), ('PUBLIC', 'Public (marketplace)'), ('PLATFORM', 'Platform Official')], db_index=True, default='PRIVATE', max_length=10)),
('category', models.CharField(choices=[('EMAIL', 'Email & Notifications'), ('REPORTS', 'Reports & Analytics'), ('CUSTOMER', 'Customer Engagement'), ('BOOKING', 'Booking & Scheduling'), ('INTEGRATION', 'Third-party Integration'), ('AUTOMATION', 'General Automation'), ('OTHER', 'Other')], db_index=True, default='OTHER', max_length=20)),
('tags', models.JSONField(blank=True, default=list, help_text="Searchable tags (e.g., ['email', 'customers', 'retention'])")),
('author_name', models.CharField(blank=True, help_text='Display name for attribution', max_length=200)),
('license_type', models.CharField(default='SCPL', help_text='SCPL for marketplace, or custom for private', max_length=10)),
('is_approved', models.BooleanField(db_index=True, default=False, help_text='Approved for marketplace by platform staff')),
('approved_at', models.DateTimeField(blank=True, null=True)),
('rejection_reason', models.TextField(blank=True, help_text='Reason for rejection if not approved')),
('install_count', models.PositiveIntegerField(default=0, help_text='Number of times installed')),
('rating_average', models.DecimalField(decimal_places=2, default=0.0, help_text='Average rating (0-5)', max_digits=3)),
('rating_count', models.PositiveIntegerField(default=0, help_text='Number of ratings')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('published_at', models.DateTimeField(blank=True, help_text='When published to marketplace', null=True)),
('approved_by', models.ForeignKey(blank=True, help_text='Platform user who approved this plugin', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_plugins', to=settings.AUTH_USER_MODEL)),
('author', models.ForeignKey(help_text='Original author', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='plugin_templates', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='PluginInstallation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('installed_at', models.DateTimeField(auto_now_add=True)),
('config_values', models.JSONField(default=dict, help_text="User's configuration values at installation")),
('template_version_hash', models.CharField(blank=True, help_text='Hash of template code at install time (for update detection)', max_length=64)),
('rating', models.PositiveSmallIntegerField(blank=True, help_text='User rating (1-5 stars)', null=True)),
('review', models.TextField(blank=True, help_text='User review/feedback')),
('reviewed_at', models.DateTimeField(blank=True, null=True)),
('installed_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='plugin_installations', to=settings.AUTH_USER_MODEL)),
('scheduled_task', models.OneToOneField(help_text='The created scheduled task', on_delete=django.db.models.deletion.CASCADE, related_name='installation', to='schedule.scheduledtask')),
('template', models.ForeignKey(help_text='Source template (null if template deleted)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='installations', to='schedule.plugintemplate')),
],
options={
'ordering': ['-installed_at'],
},
),
migrations.AddIndex(
model_name='plugintemplate',
index=models.Index(fields=['visibility', 'is_approved', '-install_count'], name='schedule_pl_visibil_5a0373_idx'),
),
migrations.AddIndex(
model_name='plugintemplate',
index=models.Index(fields=['category', 'visibility'], name='schedule_pl_categor_059ef0_idx'),
),
migrations.AddIndex(
model_name='plugintemplate',
index=models.Index(fields=['slug'], name='schedule_pl_slug_f788ca_idx'),
),
migrations.AddIndex(
model_name='plugininstallation',
index=models.Index(fields=['template', '-installed_at'], name='schedule_pl_templat_9cc5b3_idx'),
),
migrations.AddIndex(
model_name='plugininstallation',
index=models.Index(fields=['installed_by', '-installed_at'], name='schedule_pl_install_5b0143_idx'),
),
]

View File

@@ -256,6 +256,10 @@ class ScheduledTask(models.Model):
help_text="Name of the plugin to execute",
db_index=True,
)
plugin_code = models.TextField(
blank=True,
help_text="Custom plugin code (for custom scripts)"
)
plugin_config = models.JSONField(
default=dict,
blank=True,
@@ -606,3 +610,321 @@ class WhitelistedURL(models.Model):
return True
return False
class PluginTemplate(models.Model):
"""
Shareable plugin template in the marketplace.
Represents a plugin that can be shared across businesses, either:
- Platform-published: Created by platform team
- Community-shared: Created by users and approved for sharing
"""
class Visibility(models.TextChoices):
PRIVATE = 'PRIVATE', 'Private (only me)'
PUBLIC = 'PUBLIC', 'Public (marketplace)'
PLATFORM = 'PLATFORM', 'Platform Official'
class Category(models.TextChoices):
EMAIL = 'EMAIL', 'Email & Notifications'
REPORTS = 'REPORTS', 'Reports & Analytics'
CUSTOMER = 'CUSTOMER', 'Customer Engagement'
BOOKING = 'BOOKING', 'Booking & Scheduling'
INTEGRATION = 'INTEGRATION', 'Third-party Integration'
AUTOMATION = 'AUTOMATION', 'General Automation'
OTHER = 'OTHER', 'Other'
# Basic Info
name = models.CharField(
max_length=200,
help_text="Plugin name (e.g., 'Win Back Inactive Customers')"
)
slug = models.SlugField(
max_length=200,
unique=True,
help_text="URL-friendly identifier"
)
description = models.TextField(
help_text="What this plugin does (markdown supported)"
)
short_description = models.CharField(
max_length=200,
help_text="One-line summary for marketplace listing"
)
# Code & Configuration
plugin_code = models.TextField(
help_text="The Python script code"
)
plugin_code_hash = models.CharField(
max_length=64,
blank=True,
help_text="SHA-256 hash of code for verification"
)
template_variables = models.JSONField(
default=dict,
blank=True,
help_text="Template variables extracted from code (PROMPT, CONTEXT, DATE)"
)
default_config = models.JSONField(
default=dict,
blank=True,
help_text="Default configuration values"
)
# Marketplace Info
visibility = models.CharField(
max_length=10,
choices=Visibility.choices,
default=Visibility.PRIVATE,
db_index=True
)
category = models.CharField(
max_length=20,
choices=Category.choices,
default=Category.OTHER,
db_index=True
)
tags = models.JSONField(
default=list,
blank=True,
help_text="Searchable tags (e.g., ['email', 'customers', 'retention'])"
)
# Author & Licensing
author = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
related_name='plugin_templates',
help_text="Original author"
)
author_name = models.CharField(
max_length=200,
blank=True,
help_text="Display name for attribution"
)
license_type = models.CharField(
max_length=10,
default='SCPL',
help_text="SCPL for marketplace, or custom for private"
)
# Approval & Publishing
is_approved = models.BooleanField(
default=False,
db_index=True,
help_text="Approved for marketplace by platform staff"
)
approved_by = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='approved_plugins',
help_text="Platform user who approved this plugin"
)
approved_at = models.DateTimeField(
null=True,
blank=True
)
rejection_reason = models.TextField(
blank=True,
help_text="Reason for rejection if not approved"
)
# Stats & Engagement
install_count = models.PositiveIntegerField(
default=0,
help_text="Number of times installed"
)
rating_average = models.DecimalField(
max_digits=3,
decimal_places=2,
default=0.00,
help_text="Average rating (0-5)"
)
rating_count = models.PositiveIntegerField(
default=0,
help_text="Number of ratings"
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
published_at = models.DateTimeField(
null=True,
blank=True,
help_text="When published to marketplace"
)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['visibility', 'is_approved', '-install_count']),
models.Index(fields=['category', 'visibility']),
models.Index(fields=['slug']),
]
def __str__(self):
return f"{self.name} by {self.author_name or 'Platform'}"
def save(self, *args, **kwargs):
"""Generate slug and code hash on save"""
import hashlib
from django.utils.text import slugify
# Generate slug if not set
if not self.slug:
self.slug = slugify(self.name)
# Ensure uniqueness
counter = 1
original_slug = self.slug
while PluginTemplate.objects.filter(slug=self.slug).exists():
self.slug = f"{original_slug}-{counter}"
counter += 1
# Generate code hash for verification
if self.plugin_code:
self.plugin_code_hash = hashlib.sha256(
self.plugin_code.encode('utf-8')
).hexdigest()
# Set author name if not provided
if self.author and not self.author_name:
self.author_name = self.author.get_full_name() or self.author.username
super().save(*args, **kwargs)
def can_be_published(self):
"""Check if plugin meets requirements for marketplace publishing"""
from .safe_scripting import validate_plugin_whitelist
# Validate code syntax and whitelist
validation = validate_plugin_whitelist(self.plugin_code)
return validation['valid']
def publish_to_marketplace(self, user):
"""Publish plugin to marketplace"""
if not self.is_approved:
raise ValidationError("Plugin must be approved before publishing to marketplace")
self.visibility = self.Visibility.PUBLIC
self.published_at = timezone.now()
self.save()
def unpublish_from_marketplace(self):
"""Remove plugin from marketplace (existing installations remain)"""
self.visibility = self.Visibility.PRIVATE
self.save()
class PluginInstallation(models.Model):
"""
Tracks installation of a plugin template into a ScheduledTask.
When a user installs a plugin from the marketplace, we create:
1. A ScheduledTask with the configured code
2. A PluginInstallation record linking template -> task
"""
template = models.ForeignKey(
PluginTemplate,
on_delete=models.SET_NULL,
null=True,
related_name='installations',
help_text="Source template (null if template deleted)"
)
scheduled_task = models.OneToOneField(
ScheduledTask,
on_delete=models.CASCADE,
related_name='installation',
help_text="The created scheduled task"
)
# Installation metadata
installed_by = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
related_name='plugin_installations'
)
installed_at = models.DateTimeField(auto_now_add=True)
# Configuration at install time
config_values = models.JSONField(
default=dict,
help_text="User's configuration values at installation"
)
# Template version tracking
template_version_hash = models.CharField(
max_length=64,
blank=True,
help_text="Hash of template code at install time (for update detection)"
)
# User feedback
rating = models.PositiveSmallIntegerField(
null=True,
blank=True,
help_text="User rating (1-5 stars)"
)
review = models.TextField(
blank=True,
help_text="User review/feedback"
)
reviewed_at = models.DateTimeField(
null=True,
blank=True
)
class Meta:
ordering = ['-installed_at']
indexes = [
models.Index(fields=['template', '-installed_at']),
models.Index(fields=['installed_by', '-installed_at']),
]
def __str__(self):
template_name = self.template.name if self.template else "Deleted Template"
return f"{template_name} -> {self.scheduled_task.name}"
def has_update_available(self):
"""Check if template has been updated since installation"""
if not self.template:
return False
return self.template.plugin_code_hash != self.template_version_hash
def update_to_latest(self):
"""Update scheduled task to latest template version"""
if not self.template:
raise ValidationError("Cannot update: template has been deleted")
# Update scheduled task with latest code
self.scheduled_task.plugin_code = self.template.plugin_code
self.scheduled_task.save()
# Update version hash
self.template_version_hash = self.template.plugin_code_hash
self.save()

View File

@@ -4,7 +4,7 @@ DRF Serializers for Schedule App with Availability Validation
from rest_framework import serializers
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation
from .services import AvailabilityService
from smoothschedule.users.models import User
@@ -549,3 +549,122 @@ class PluginInfoSerializer(serializers.Serializer):
description = serializers.CharField()
category = serializers.CharField()
config_schema = serializers.DictField()
class PluginTemplateSerializer(serializers.ModelSerializer):
"""Serializer for PluginTemplate model"""
author_name = serializers.CharField(read_only=True)
approved_by_name = serializers.SerializerMethodField()
can_publish = serializers.SerializerMethodField()
validation_errors = serializers.SerializerMethodField()
class Meta:
model = PluginTemplate
fields = [
'id', 'name', 'slug', 'description', 'short_description',
'plugin_code', 'plugin_code_hash', 'template_variables', 'default_config',
'visibility', 'category', 'tags',
'author', 'author_name', 'license_type',
'is_approved', 'approved_by', 'approved_by_name', 'approved_at', 'rejection_reason',
'install_count', 'rating_average', 'rating_count',
'created_at', 'updated_at', 'published_at',
'can_publish', 'validation_errors',
]
read_only_fields = [
'id', 'slug', 'plugin_code_hash', 'template_variables',
'author', 'author_name', 'is_approved', 'approved_by', 'approved_by_name',
'approved_at', 'rejection_reason', 'install_count', 'rating_average',
'rating_count', 'created_at', 'updated_at', 'published_at',
]
def get_approved_by_name(self, obj):
"""Get name of user who approved the plugin"""
if obj.approved_by:
return obj.approved_by.get_full_name() or obj.approved_by.username
return None
def get_can_publish(self, obj):
"""Check if plugin can be published to marketplace"""
return obj.can_be_published()
def get_validation_errors(self, obj):
"""Get validation errors for publishing"""
from .safe_scripting import validate_plugin_whitelist
validation = validate_plugin_whitelist(obj.plugin_code)
if not validation['valid']:
return validation['errors']
return []
def create(self, validated_data):
"""Set author from request user"""
request = self.context.get('request')
if request and hasattr(request, 'user'):
validated_data['author'] = request.user
return super().create(validated_data)
def validate_plugin_code(self, value):
"""Validate plugin code and extract template variables"""
if not value or not value.strip():
raise serializers.ValidationError("Plugin code cannot be empty")
# Extract template variables
from .template_parser import TemplateVariableParser
try:
template_vars = TemplateVariableParser.extract_variables(value)
except Exception as e:
raise serializers.ValidationError(f"Failed to parse template variables: {str(e)}")
return value
class PluginTemplateListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for plugin template listing"""
author_name = serializers.CharField(read_only=True)
class Meta:
model = PluginTemplate
fields = [
'id', 'name', 'slug', 'short_description',
'visibility', 'category', 'tags',
'author_name', 'license_type', 'is_approved',
'install_count', 'rating_average', 'rating_count',
'created_at', 'updated_at', 'published_at',
]
read_only_fields = '__all__'
class PluginInstallationSerializer(serializers.ModelSerializer):
"""Serializer for PluginInstallation model"""
template_name = serializers.CharField(source='template.name', read_only=True)
template_slug = serializers.CharField(source='template.slug', read_only=True)
scheduled_task_name = serializers.CharField(source='scheduled_task.name', read_only=True)
installed_by_name = serializers.SerializerMethodField()
has_update = serializers.SerializerMethodField()
class Meta:
model = PluginInstallation
fields = [
'id', 'template', 'template_name', 'template_slug',
'scheduled_task', 'scheduled_task_name',
'installed_by', 'installed_by_name', 'installed_at',
'config_values', 'template_version_hash',
'rating', 'review', 'reviewed_at',
'has_update',
]
read_only_fields = [
'id', 'installed_by', 'installed_by_name', 'installed_at',
'template_version_hash', 'reviewed_at',
]
def get_installed_by_name(self, obj):
"""Get name of user who installed the plugin"""
if obj.installed_by:
return obj.installed_by.get_full_name() or obj.installed_by.username
return None
def get_has_update(self, obj):
"""Check if template has been updated"""
return obj.has_update_available()

View File

@@ -6,7 +6,8 @@ from rest_framework.routers import DefaultRouter
from .views import (
ResourceViewSet, EventViewSet, ParticipantViewSet,
CustomerViewSet, ServiceViewSet, StaffViewSet, ResourceTypeViewSet,
ScheduledTaskViewSet, TaskExecutionLogViewSet, PluginViewSet
ScheduledTaskViewSet, TaskExecutionLogViewSet, PluginViewSet,
PluginTemplateViewSet, PluginInstallationViewSet
)
# Create router and register viewsets
@@ -22,6 +23,8 @@ router.register(r'staff', StaffViewSet, basename='staff')
router.register(r'scheduled-tasks', ScheduledTaskViewSet, basename='scheduledtask')
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')
# URL patterns
urlpatterns = [

View File

@@ -8,11 +8,12 @@ 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
from .models import Resource, Event, Participant, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation
from .serializers import (
ResourceSerializer, EventSerializer, ParticipantSerializer,
CustomerSerializer, ServiceSerializer, ResourceTypeSerializer, StaffSerializer,
ScheduledTaskSerializer, TaskExecutionLogSerializer, PluginInfoSerializer
ScheduledTaskSerializer, TaskExecutionLogSerializer, PluginInfoSerializer,
PluginTemplateSerializer, PluginTemplateListSerializer, PluginInstallationSerializer
)
from .models import Service
from core.permissions import HasQuota
@@ -605,3 +606,427 @@ class PluginViewSet(viewsets.ViewSet):
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)