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:
@@ -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.'
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -256,6 +256,10 @@ class ScheduledTask(models.Model):
|
|||||||
help_text="Name of the plugin to execute",
|
help_text="Name of the plugin to execute",
|
||||||
db_index=True,
|
db_index=True,
|
||||||
)
|
)
|
||||||
|
plugin_code = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Custom plugin code (for custom scripts)"
|
||||||
|
)
|
||||||
plugin_config = models.JSONField(
|
plugin_config = models.JSONField(
|
||||||
default=dict,
|
default=dict,
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -606,3 +610,321 @@ class WhitelistedURL(models.Model):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
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()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ DRF Serializers for Schedule App with Availability Validation
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
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 .services import AvailabilityService
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
@@ -549,3 +549,122 @@ class PluginInfoSerializer(serializers.Serializer):
|
|||||||
description = serializers.CharField()
|
description = serializers.CharField()
|
||||||
category = serializers.CharField()
|
category = serializers.CharField()
|
||||||
config_schema = serializers.DictField()
|
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()
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from rest_framework.routers import DefaultRouter
|
|||||||
from .views import (
|
from .views import (
|
||||||
ResourceViewSet, EventViewSet, ParticipantViewSet,
|
ResourceViewSet, EventViewSet, ParticipantViewSet,
|
||||||
CustomerViewSet, ServiceViewSet, StaffViewSet, ResourceTypeViewSet,
|
CustomerViewSet, ServiceViewSet, StaffViewSet, ResourceTypeViewSet,
|
||||||
ScheduledTaskViewSet, TaskExecutionLogViewSet, PluginViewSet
|
ScheduledTaskViewSet, TaskExecutionLogViewSet, PluginViewSet,
|
||||||
|
PluginTemplateViewSet, PluginInstallationViewSet
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create router and register viewsets
|
# 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'scheduled-tasks', ScheduledTaskViewSet, basename='scheduledtask')
|
||||||
router.register(r'task-logs', TaskExecutionLogViewSet, basename='tasklog')
|
router.register(r'task-logs', TaskExecutionLogViewSet, basename='tasklog')
|
||||||
router.register(r'plugins', PluginViewSet, basename='plugin')
|
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
|
# URL patterns
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ from rest_framework.permissions import IsAuthenticated, AllowAny
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
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 (
|
from .serializers import (
|
||||||
ResourceSerializer, EventSerializer, ParticipantSerializer,
|
ResourceSerializer, EventSerializer, ParticipantSerializer,
|
||||||
CustomerSerializer, ServiceSerializer, ResourceTypeSerializer, StaffSerializer,
|
CustomerSerializer, ServiceSerializer, ResourceTypeSerializer, StaffSerializer,
|
||||||
ScheduledTaskSerializer, TaskExecutionLogSerializer, PluginInfoSerializer
|
ScheduledTaskSerializer, TaskExecutionLogSerializer, PluginInfoSerializer,
|
||||||
|
PluginTemplateSerializer, PluginTemplateListSerializer, PluginInstallationSerializer
|
||||||
)
|
)
|
||||||
from .models import Service
|
from .models import Service
|
||||||
from core.permissions import HasQuota
|
from core.permissions import HasQuota
|
||||||
@@ -605,3 +606,427 @@ class PluginViewSet(viewsets.ViewSet):
|
|||||||
|
|
||||||
serializer = PluginInfoSerializer(plugin_info)
|
serializer = PluginInfoSerializer(plugin_info)
|
||||||
return Response(serializer.data)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user