diff --git a/smoothschedule/schedule/management/commands/seed_platform_plugins.py b/smoothschedule/schedule/management/commands/seed_platform_plugins.py new file mode 100644 index 0000000..d447669 --- /dev/null +++ b/smoothschedule/schedule/management/commands/seed_platform_plugins.py @@ -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.' + ) + ) diff --git a/smoothschedule/schedule/migrations/0015_scheduledtask_plugin_code_plugintemplate_and_more.py b/smoothschedule/schedule/migrations/0015_scheduledtask_plugin_code_plugintemplate_and_more.py new file mode 100644 index 0000000..ccb087c --- /dev/null +++ b/smoothschedule/schedule/migrations/0015_scheduledtask_plugin_code_plugintemplate_and_more.py @@ -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'), + ), + ] diff --git a/smoothschedule/schedule/models.py b/smoothschedule/schedule/models.py index a9b98cc..081843f 100644 --- a/smoothschedule/schedule/models.py +++ b/smoothschedule/schedule/models.py @@ -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() diff --git a/smoothschedule/schedule/serializers.py b/smoothschedule/schedule/serializers.py index 440104d..3cbe53c 100644 --- a/smoothschedule/schedule/serializers.py +++ b/smoothschedule/schedule/serializers.py @@ -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() diff --git a/smoothschedule/schedule/urls.py b/smoothschedule/schedule/urls.py index 80eb751..12cf377 100644 --- a/smoothschedule/schedule/urls.py +++ b/smoothschedule/schedule/urls.py @@ -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 = [ diff --git a/smoothschedule/schedule/views.py b/smoothschedule/schedule/views.py index e25bb84..edc6ee4 100644 --- a/smoothschedule/schedule/views.py +++ b/smoothschedule/schedule/views.py @@ -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)