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",
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user