plugin distribution system

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-09 21:38:14 +00:00
parent cf8181d1f9
commit 80f1709a2e
22 changed files with 2804 additions and 35 deletions

View File

@@ -0,0 +1 @@
# IGNY8 Plugin Distribution System

View File

@@ -0,0 +1,173 @@
"""
Django Admin Configuration for Plugin Distribution System
"""
from django.contrib import admin
from django.utils.html import format_html
from django.urls import reverse
from unfold.admin import ModelAdmin, TabularInline
from .models import Plugin, PluginVersion, PluginInstallation, PluginDownload
class PluginVersionInline(TabularInline):
"""Inline admin for plugin versions."""
model = PluginVersion
extra = 0
fields = ['version', 'status', 'file_size', 'released_at', 'download_count']
readonly_fields = ['download_count']
ordering = ['-version_code']
def download_count(self, obj):
return obj.get_download_count()
download_count.short_description = 'Downloads'
@admin.register(Plugin)
class PluginAdmin(ModelAdmin):
"""Admin configuration for Plugin model."""
list_display = ['name', 'slug', 'platform', 'is_active', 'latest_version', 'total_downloads', 'created_at']
list_filter = ['platform', 'is_active', 'created_at']
search_fields = ['name', 'slug', 'description']
readonly_fields = ['created_at', 'updated_at', 'total_downloads', 'active_installations']
fieldsets = [
('Basic Info', {
'fields': ['name', 'slug', 'platform', 'description', 'homepage_url', 'is_active']
}),
('Statistics', {
'fields': ['total_downloads', 'active_installations'],
'classes': ['collapse']
}),
('Timestamps', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
inlines = [PluginVersionInline]
def latest_version(self, obj):
latest = obj.get_latest_version()
if latest:
return f"v{latest.version}"
return "-"
latest_version.short_description = 'Latest Version'
def total_downloads(self, obj):
return obj.get_download_count()
total_downloads.short_description = 'Total Downloads'
def active_installations(self, obj):
return PluginInstallation.objects.filter(plugin=obj, is_active=True).count()
active_installations.short_description = 'Active Installations'
@admin.register(PluginVersion)
class PluginVersionAdmin(ModelAdmin):
"""Admin configuration for PluginVersion model."""
list_display = ['plugin', 'version', 'status', 'file_size_display', 'download_count', 'released_at']
list_filter = ['plugin', 'status', 'released_at']
search_fields = ['plugin__name', 'version', 'changelog']
readonly_fields = ['version_code', 'created_at', 'download_count']
fieldsets = [
('Version Info', {
'fields': ['plugin', 'version', 'version_code', 'status']
}),
('File Info', {
'fields': ['file_path', 'file_size', 'checksum']
}),
('Requirements', {
'fields': ['min_api_version', 'min_platform_version', 'min_php_version']
}),
('Release', {
'fields': ['changelog', 'force_update', 'released_at']
}),
('Statistics', {
'fields': ['download_count'],
'classes': ['collapse']
}),
('Timestamps', {
'fields': ['created_at'],
'classes': ['collapse']
}),
]
actions = ['release_versions', 'mark_as_update_ready']
def file_size_display(self, obj):
if obj.file_size:
kb = obj.file_size / 1024
if kb > 1024:
return f"{kb / 1024:.1f} MB"
return f"{kb:.1f} KB"
return "-"
file_size_display.short_description = 'Size'
def download_count(self, obj):
return obj.get_download_count()
download_count.short_description = 'Downloads'
@admin.action(description="Release selected versions")
def release_versions(self, request, queryset):
from django.utils import timezone
count = queryset.filter(status__in=['draft', 'testing', 'staged']).update(
status='released',
released_at=timezone.now()
)
self.message_user(request, f"Released {count} version(s)")
@admin.action(description="Mark as update ready (push to installations)")
def mark_as_update_ready(self, request, queryset):
count = queryset.filter(status='released').update(status='update_ready')
self.message_user(request, f"Marked {count} version(s) as update ready")
@admin.register(PluginInstallation)
class PluginInstallationAdmin(ModelAdmin):
"""Admin configuration for PluginInstallation model."""
list_display = ['site', 'plugin', 'current_version', 'is_active', 'health_status', 'last_health_check']
list_filter = ['plugin', 'is_active', 'health_status']
search_fields = ['site__name', 'plugin__name']
readonly_fields = ['created_at', 'updated_at']
fieldsets = [
('Installation', {
'fields': ['site', 'plugin', 'current_version', 'is_active']
}),
('Health', {
'fields': ['health_status', 'last_health_check']
}),
('Updates', {
'fields': ['pending_update', 'update_notified_at']
}),
('Timestamps', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
@admin.register(PluginDownload)
class PluginDownloadAdmin(ModelAdmin):
"""Admin configuration for PluginDownload model."""
list_display = ['plugin', 'version', 'site', 'download_type', 'ip_address', 'created_at']
list_filter = ['plugin', 'download_type', 'created_at']
search_fields = ['plugin__name', 'site__name', 'ip_address']
readonly_fields = ['created_at']
date_hierarchy = 'created_at'
fieldsets = [
('Download Info', {
'fields': ['plugin', 'version', 'download_type']
}),
('Context', {
'fields': ['site', 'account', 'ip_address', 'user_agent']
}),
('Timestamp', {
'fields': ['created_at']
}),
]

View File

@@ -0,0 +1,15 @@
"""
Plugin Distribution System App Configuration
"""
from django.apps import AppConfig
class PluginsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.plugins'
label = 'plugins'
verbose_name = 'Plugin Distribution'
def ready(self):
"""Import signal handlers when app is ready."""
pass

View File

@@ -0,0 +1 @@
# Plugin management commands

View File

@@ -0,0 +1 @@
# Plugin management commands

View File

@@ -0,0 +1,164 @@
"""
Django management command to build a plugin and register its version.
Usage:
python manage.py build_plugin --plugin=igny8-wp-bridge --version=1.0.1 [--changelog="Bug fixes"] [--release]
"""
import subprocess
import hashlib
import os
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from igny8_core.plugins.models import Plugin, PluginVersion
from igny8_core.plugins.utils import (
create_plugin_zip,
get_plugin_file_path,
calculate_checksum,
parse_version_to_code,
)
class Command(BaseCommand):
help = 'Build and register a new plugin version'
def add_arguments(self, parser):
parser.add_argument(
'--plugin',
type=str,
required=True,
help='Plugin slug (e.g., igny8-wp-bridge)'
)
parser.add_argument(
'--plugin-version',
type=str,
required=True,
dest='plugin_version',
help='Version string (e.g., 1.0.1)'
)
parser.add_argument(
'--changelog',
type=str,
default='',
help='Changelog for this version'
)
parser.add_argument(
'--release',
action='store_true',
help='Immediately release this version'
)
parser.add_argument(
'--force',
action='store_true',
help='Overwrite existing version'
)
parser.add_argument(
'--no-build',
action='store_true',
help='Skip building, only register existing ZIP'
)
def handle(self, *args, **options):
plugin_slug = options['plugin']
version = options['plugin_version']
changelog = options['changelog']
release = options['release']
force = options['force']
no_build = options['no_build']
# Validate version format
import re
if not re.match(r'^\d+\.\d+\.\d+$', version):
raise CommandError('Version must follow semantic versioning (e.g., 1.0.0)')
# Get or create plugin
try:
plugin = Plugin.objects.get(slug=plugin_slug)
self.stdout.write(f"Found plugin: {plugin.name}")
except Plugin.DoesNotExist:
# Create plugin if it doesn't exist
if plugin_slug == 'igny8-wp-bridge':
plugin = Plugin.objects.create(
name='IGNY8 WordPress Bridge',
slug='igny8-wp-bridge',
platform='wordpress',
description='Lightweight bridge plugin that connects WordPress to IGNY8 API for one-way content publishing.',
homepage_url='https://igny8.com',
is_active=True,
)
self.stdout.write(self.style.SUCCESS(f"Created plugin: {plugin.name}"))
else:
raise CommandError(f"Plugin not found: {plugin_slug}")
# Check if version already exists
existing = PluginVersion.objects.filter(plugin=plugin, version=version).first()
if existing and not force:
raise CommandError(
f"Version {version} already exists. Use --force to overwrite."
)
# Build plugin ZIP
if not no_build:
self.stdout.write(f"Building {plugin.name} v{version}...")
file_path, checksum, file_size = create_plugin_zip(
platform=plugin.platform,
plugin_slug=plugin.slug,
version=version,
update_version=True
)
if not file_path:
raise CommandError("Failed to build plugin ZIP")
self.stdout.write(self.style.SUCCESS(f"Built: {file_path}"))
self.stdout.write(f" Size: {file_size:,} bytes")
self.stdout.write(f" Checksum: {checksum}")
else:
# Find existing ZIP
file_path = f"{plugin.slug}-v{version}.zip"
full_path = get_plugin_file_path(plugin.platform, file_path)
if not full_path:
raise CommandError(f"ZIP file not found: {file_path}")
checksum = calculate_checksum(str(full_path))
file_size = full_path.stat().st_size
self.stdout.write(f"Using existing ZIP: {file_path}")
# Calculate version code
version_code = parse_version_to_code(version)
# Create or update version record
if existing:
existing.version_code = version_code
existing.file_path = file_path
existing.file_size = file_size
existing.checksum = checksum
existing.changelog = changelog
if release:
existing.status = 'released'
existing.released_at = timezone.now()
existing.save()
self.stdout.write(self.style.SUCCESS(f"Updated version {version}"))
else:
plugin_version = PluginVersion.objects.create(
plugin=plugin,
version=version,
version_code=version_code,
file_path=file_path,
file_size=file_size,
checksum=checksum,
changelog=changelog,
status='released' if release else 'draft',
released_at=timezone.now() if release else None,
)
self.stdout.write(self.style.SUCCESS(f"Created version {version}"))
if release:
self.stdout.write(self.style.SUCCESS(f"Version {version} is now released!"))
else:
self.stdout.write(
f"Version {version} created as draft. "
f"Use --release to make it available for download."
)

View File

@@ -0,0 +1,99 @@
"""
Django management command to push a plugin update to all installations.
Usage:
python manage.py push_plugin_update --plugin=igny8-wp-bridge --version=1.0.1
"""
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from igny8_core.plugins.models import Plugin, PluginVersion, PluginInstallation
class Command(BaseCommand):
help = 'Push a plugin update to all active installations'
def add_arguments(self, parser):
parser.add_argument(
'--plugin',
type=str,
required=True,
help='Plugin slug (e.g., igny8-wp-bridge)'
)
parser.add_argument(
'--plugin-version',
type=str,
required=True,
dest='plugin_version',
help='Version string (e.g., 1.0.1)'
)
parser.add_argument(
'--force',
action='store_true',
help='Mark as force update (critical security fix)'
)
def handle(self, *args, **options):
plugin_slug = options['plugin']
version_str = options['plugin_version']
force = options['force']
# Get plugin
try:
plugin = Plugin.objects.get(slug=plugin_slug)
except Plugin.DoesNotExist:
raise CommandError(f"Plugin not found: {plugin_slug}")
# Get version
try:
version = PluginVersion.objects.get(plugin=plugin, version=version_str)
except PluginVersion.DoesNotExist:
raise CommandError(f"Version not found: {version_str}")
# Check if released
if version.status not in ['released', 'update_ready']:
raise CommandError(
f"Version {version_str} must be released before pushing update. "
f"Current status: {version.status}"
)
# Mark as update ready
version.status = 'update_ready'
if force:
version.force_update = True
version.save(update_fields=['status', 'force_update'])
# Update all installations
installations = PluginInstallation.objects.filter(
plugin=plugin,
is_active=True
).exclude(current_version=version)
updated_count = installations.update(
pending_update=version,
update_notified_at=timezone.now()
)
self.stdout.write(
self.style.SUCCESS(
f"Pushed {plugin.name} v{version_str} to {updated_count} installations"
)
)
if force:
self.stdout.write(
self.style.WARNING(
"This is marked as a FORCE update (critical security fix)"
)
)
# Show summary
total_installations = PluginInstallation.objects.filter(
plugin=plugin,
is_active=True
).count()
already_updated = total_installations - updated_count
if already_updated > 0:
self.stdout.write(
f" {already_updated} installations already have v{version_str}"
)

View File

@@ -0,0 +1,149 @@
"""
Django management command to register an existing plugin version.
Useful for registering a ZIP file that was built manually or externally.
Usage:
python manage.py register_plugin_version --plugin=igny8-wp-bridge --version=1.0.1 [--changelog="Bug fixes"]
"""
import os
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from igny8_core.plugins.models import Plugin, PluginVersion
from igny8_core.plugins.utils import (
get_plugin_file_path,
calculate_checksum,
parse_version_to_code,
get_dist_path,
)
class Command(BaseCommand):
help = 'Register an existing plugin ZIP file as a version'
def add_arguments(self, parser):
parser.add_argument(
'--plugin',
type=str,
required=True,
help='Plugin slug (e.g., igny8-wp-bridge)'
)
parser.add_argument(
'--plugin-version',
type=str,
required=True,
dest='plugin_version',
help='Version string (e.g., 1.0.1)'
)
parser.add_argument(
'--changelog',
type=str,
default='',
help='Changelog for this version'
)
parser.add_argument(
'--release',
action='store_true',
help='Immediately release this version'
)
parser.add_argument(
'--min-php',
type=str,
default='7.4',
help='Minimum PHP version required'
)
def handle(self, *args, **options):
plugin_slug = options['plugin']
version_str = options['plugin_version']
changelog = options['changelog']
release = options['release']
min_php = options['min_php']
# Validate version format
import re
if not re.match(r'^\d+\.\d+\.\d+$', version_str):
raise CommandError('Version must follow semantic versioning (e.g., 1.0.0)')
# Get plugin
try:
plugin = Plugin.objects.get(slug=plugin_slug)
except Plugin.DoesNotExist:
# Create WordPress plugin if it doesn't exist
if plugin_slug == 'igny8-wp-bridge':
plugin = Plugin.objects.create(
name='IGNY8 WordPress Bridge',
slug='igny8-wp-bridge',
platform='wordpress',
description='Lightweight bridge plugin that connects WordPress to IGNY8 API for one-way content publishing.',
homepage_url='https://igny8.com',
is_active=True,
)
self.stdout.write(self.style.SUCCESS(f"Created plugin: {plugin.name}"))
else:
raise CommandError(f"Plugin not found: {plugin_slug}")
# Check if version already exists
if PluginVersion.objects.filter(plugin=plugin, version=version_str).exists():
raise CommandError(f"Version {version_str} already exists for {plugin.name}")
# Check if ZIP file exists
file_name = f"{plugin.slug}-v{version_str}.zip"
file_path = get_plugin_file_path(plugin.platform, file_name)
if not file_path:
# Also check for latest symlink
dist_path = get_dist_path(plugin.platform)
latest_link = dist_path / f"{plugin.slug}-latest.zip"
if latest_link.exists():
self.stdout.write(
self.style.WARNING(
f"ZIP file not found: {file_name}\n"
f"Found latest symlink. Creating expected filename..."
)
)
# Could copy or rename, but for now just fail
raise CommandError(
f"ZIP file not found: {file_name}\n"
f"Expected at: {dist_path}/{file_name}\n"
f"Build the plugin first with: ./scripts/build-wp-plugin.sh {version_str}"
)
# Calculate checksum and file size
checksum = calculate_checksum(str(file_path))
file_size = file_path.stat().st_size
# Calculate version code
version_code = parse_version_to_code(version_str)
# Create version record
plugin_version = PluginVersion.objects.create(
plugin=plugin,
version=version_str,
version_code=version_code,
file_path=file_name,
file_size=file_size,
checksum=checksum,
changelog=changelog,
min_php_version=min_php,
status='released' if release else 'draft',
released_at=timezone.now() if release else None,
)
self.stdout.write(
self.style.SUCCESS(
f"Registered {plugin.name} v{version_str}\n"
f" File: {file_name}\n"
f" Size: {file_size:,} bytes\n"
f" Checksum: {checksum}\n"
f" Status: {'released' if release else 'draft'}"
)
)
if not release:
self.stdout.write(
f"\nTo release this version, run:\n"
f" python manage.py release_plugin --plugin={plugin_slug} --version={version_str}"
)

View File

@@ -0,0 +1,70 @@
"""
Django management command to release a plugin version.
Usage:
python manage.py release_plugin --plugin=igny8-wp-bridge --version=1.0.1
"""
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from igny8_core.plugins.models import Plugin, PluginVersion
class Command(BaseCommand):
help = 'Release a plugin version (make it available for download)'
def add_arguments(self, parser):
parser.add_argument(
'--plugin',
type=str,
required=True,
help='Plugin slug (e.g., igny8-wp-bridge)'
)
parser.add_argument(
'--plugin-version',
type=str,
required=True,
dest='plugin_version',
help='Version string (e.g., 1.0.1)'
)
def handle(self, *args, **options):
plugin_slug = options['plugin']
version_str = options['plugin_version']
# Get plugin
try:
plugin = Plugin.objects.get(slug=plugin_slug)
except Plugin.DoesNotExist:
raise CommandError(f"Plugin not found: {plugin_slug}")
# Get version
try:
version = PluginVersion.objects.get(plugin=plugin, version=version_str)
except PluginVersion.DoesNotExist:
raise CommandError(f"Version not found: {version_str}")
# Check if already released
if version.status in ['released', 'update_ready']:
self.stdout.write(
self.style.WARNING(f"Version {version_str} is already released")
)
return
# Check if file exists
if not version.file_path:
raise CommandError(
f"No file associated with version {version_str}. "
f"Build the plugin first with: python manage.py build_plugin"
)
# Release
version.status = 'released'
version.released_at = timezone.now()
version.save(update_fields=['status', 'released_at'])
self.stdout.write(
self.style.SUCCESS(
f"Released {plugin.name} v{version_str}\n"
f" Download URL: /api/plugins/{plugin.slug}/download/"
)
)

View File

@@ -0,0 +1,107 @@
# Generated by Django 5.2.10 on 2026-01-09 20:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('igny8_core_auth', '0020_fix_historical_account'),
]
operations = [
migrations.CreateModel(
name='Plugin',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text="Human-readable plugin name (e.g., 'IGNY8 WP Bridge')", max_length=100)),
('slug', models.SlugField(help_text="URL-safe identifier (e.g., 'igny8-wp-bridge')", unique=True)),
('platform', models.CharField(choices=[('wordpress', 'WordPress'), ('shopify', 'Shopify'), ('custom', 'Custom Site')], help_text='Target platform for this plugin', max_length=20)),
('description', models.TextField(blank=True, help_text='Plugin description for display in download pages')),
('homepage_url', models.URLField(blank=True, help_text='Plugin homepage or documentation URL')),
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this plugin is available for download')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Plugin',
'verbose_name_plural': 'Plugins',
'db_table': 'plugins',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='PluginVersion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version', models.CharField(help_text="Semantic version string (e.g., '1.0.0', '1.0.1')", max_length=20)),
('version_code', models.IntegerField(help_text='Numeric version for comparison (1.0.1 -> 10001)')),
('status', models.CharField(choices=[('draft', 'Draft'), ('testing', 'Testing'), ('staged', 'Staged'), ('released', 'Released'), ('update_ready', 'Update Ready'), ('deprecated', 'Deprecated')], db_index=True, default='draft', help_text='Release status of this version', max_length=20)),
('file_path', models.CharField(help_text='Relative path to ZIP file in dist/ directory', max_length=500)),
('file_size', models.IntegerField(default=0, help_text='File size in bytes')),
('checksum', models.CharField(blank=True, help_text='SHA256 checksum for integrity verification', max_length=64)),
('changelog', models.TextField(blank=True, help_text="What's new in this version (supports Markdown)")),
('min_api_version', models.CharField(default='1.0', help_text='Minimum IGNY8 API version required', max_length=20)),
('min_platform_version', models.CharField(blank=True, help_text='Minimum platform version (e.g., WordPress 5.0)', max_length=20)),
('min_php_version', models.CharField(default='7.4', help_text='Minimum PHP version required (for WordPress plugins)', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('released_at', models.DateTimeField(blank=True, help_text='When this version was released', null=True)),
('force_update', models.BooleanField(default=False, help_text='Force update for critical security fixes')),
('plugin', models.ForeignKey(help_text='Plugin this version belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='plugins.plugin')),
],
options={
'verbose_name': 'Plugin Version',
'verbose_name_plural': 'Plugin Versions',
'db_table': 'plugin_versions',
'ordering': ['-version_code'],
'unique_together': {('plugin', 'version')},
},
),
migrations.CreateModel(
name='PluginInstallation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether the plugin is currently active')),
('last_health_check', models.DateTimeField(blank=True, help_text='Last successful health check timestamp', null=True)),
('health_status', models.CharField(choices=[('healthy', 'Healthy'), ('outdated', 'Outdated'), ('error', 'Error'), ('unknown', 'Unknown')], default='unknown', help_text='Current health status', max_length=20)),
('update_notified_at', models.DateTimeField(blank=True, help_text='When site was notified about pending update', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('plugin', models.ForeignKey(help_text='Installed plugin', on_delete=django.db.models.deletion.CASCADE, related_name='installations', to='plugins.plugin')),
('site', models.ForeignKey(help_text='Site where plugin is installed', on_delete=django.db.models.deletion.CASCADE, related_name='plugin_installations', to='igny8_core_auth.site')),
('current_version', models.ForeignKey(help_text='Currently installed version', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='current_installations', to='plugins.pluginversion')),
('pending_update', models.ForeignKey(blank=True, help_text='Pending version update', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pending_installations', to='plugins.pluginversion')),
],
options={
'verbose_name': 'Plugin Installation',
'verbose_name_plural': 'Plugin Installations',
'db_table': 'plugin_installations',
'ordering': ['-updated_at'],
'unique_together': {('site', 'plugin')},
},
),
migrations.CreateModel(
name='PluginDownload',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.CharField(blank=True, max_length=500)),
('download_type', models.CharField(choices=[('manual', 'Manual Download'), ('update', 'Auto Update'), ('api', 'API Download')], default='manual', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('account', models.ForeignKey(blank=True, help_text='Account that initiated the download', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='plugin_downloads', to='igny8_core_auth.account')),
('plugin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='downloads', to='plugins.plugin')),
('site', models.ForeignKey(blank=True, help_text='Site that initiated the download (if authenticated)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='plugin_downloads', to='igny8_core_auth.site')),
('version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='downloads', to='plugins.pluginversion')),
],
options={
'verbose_name': 'Plugin Download',
'verbose_name_plural': 'Plugin Downloads',
'db_table': 'plugin_downloads',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['plugin', 'created_at'], name='plugin_down_plugin__5771ff_idx'), models.Index(fields=['version', 'created_at'], name='plugin_down_version_2bcf49_idx')],
},
),
]

View File

@@ -0,0 +1,384 @@
"""
Plugin Distribution System Models
Tracks plugins, versions, and installations across sites.
"""
from django.db import models
from django.conf import settings
from django.utils import timezone
class Plugin(models.Model):
"""
Represents a plugin type (WordPress, Shopify, etc.)
Each plugin can have multiple versions and be installed on multiple sites.
"""
PLATFORM_CHOICES = [
('wordpress', 'WordPress'),
('shopify', 'Shopify'),
('custom', 'Custom Site'),
]
name = models.CharField(
max_length=100,
help_text="Human-readable plugin name (e.g., 'IGNY8 WP Bridge')"
)
slug = models.SlugField(
unique=True,
help_text="URL-safe identifier (e.g., 'igny8-wp-bridge')"
)
platform = models.CharField(
max_length=20,
choices=PLATFORM_CHOICES,
help_text="Target platform for this plugin"
)
description = models.TextField(
blank=True,
help_text="Plugin description for display in download pages"
)
homepage_url = models.URLField(
blank=True,
help_text="Plugin homepage or documentation URL"
)
is_active = models.BooleanField(
default=True,
db_index=True,
help_text="Whether this plugin is available for download"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'plugins'
ordering = ['name']
verbose_name = 'Plugin'
verbose_name_plural = 'Plugins'
def __str__(self):
return f"{self.name} ({self.platform})"
def get_latest_version(self):
"""Get the latest released version of this plugin."""
return self.versions.filter(
status__in=['released', 'update_ready']
).first()
def get_download_count(self):
"""Get total download count across all versions."""
return self.downloads.count()
class PluginVersion(models.Model):
"""
Tracks each version of a plugin.
Versions follow semantic versioning (major.minor.patch).
"""
STATUS_CHOICES = [
('draft', 'Draft'), # In development
('testing', 'Testing'), # Internal testing
('staged', 'Staged'), # Ready, not yet pushed
('released', 'Released'), # Available for download
('update_ready', 'Update Ready'), # Push to installed sites
('deprecated', 'Deprecated'), # Old version, not recommended
]
plugin = models.ForeignKey(
Plugin,
on_delete=models.CASCADE,
related_name='versions',
help_text="Plugin this version belongs to"
)
version = models.CharField(
max_length=20,
help_text="Semantic version string (e.g., '1.0.0', '1.0.1')"
)
version_code = models.IntegerField(
help_text="Numeric version for comparison (1.0.1 -> 10001)"
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='draft',
db_index=True,
help_text="Release status of this version"
)
# File info
file_path = models.CharField(
max_length=500,
help_text="Relative path to ZIP file in dist/ directory"
)
file_size = models.IntegerField(
default=0,
help_text="File size in bytes"
)
checksum = models.CharField(
max_length=64,
blank=True,
help_text="SHA256 checksum for integrity verification"
)
# Release info
changelog = models.TextField(
blank=True,
help_text="What's new in this version (supports Markdown)"
)
min_api_version = models.CharField(
max_length=20,
default='1.0',
help_text="Minimum IGNY8 API version required"
)
min_platform_version = models.CharField(
max_length=20,
blank=True,
help_text="Minimum platform version (e.g., WordPress 5.0)"
)
min_php_version = models.CharField(
max_length=10,
default='7.4',
help_text="Minimum PHP version required (for WordPress plugins)"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
released_at = models.DateTimeField(
null=True,
blank=True,
help_text="When this version was released"
)
# Auto-update control
force_update = models.BooleanField(
default=False,
help_text="Force update for critical security fixes"
)
class Meta:
db_table = 'plugin_versions'
unique_together = ['plugin', 'version']
ordering = ['-version_code']
verbose_name = 'Plugin Version'
verbose_name_plural = 'Plugin Versions'
def __str__(self):
return f"{self.plugin.name} v{self.version}"
def save(self, *args, **kwargs):
"""Calculate version_code from version string if not set."""
if not self.version_code:
self.version_code = self.parse_version_code(self.version)
super().save(*args, **kwargs)
@staticmethod
def parse_version_code(version_string):
"""
Convert version string to numeric code for comparison.
'1.0.0' -> 10000
'1.0.1' -> 10001
'1.2.3' -> 10203
'2.0.0' -> 20000
"""
try:
parts = version_string.split('.')
major = int(parts[0]) if len(parts) > 0 else 0
minor = int(parts[1]) if len(parts) > 1 else 0
patch = int(parts[2]) if len(parts) > 2 else 0
return major * 10000 + minor * 100 + patch
except (ValueError, IndexError):
return 0
def release(self):
"""Mark this version as released."""
self.status = 'released'
self.released_at = timezone.now()
self.save(update_fields=['status', 'released_at'])
def get_download_count(self):
"""Get download count for this version."""
return self.downloads.count()
class PluginInstallation(models.Model):
"""
Tracks where plugins are installed (per site).
This allows us to:
- Notify sites about available updates
- Track version distribution
- Monitor plugin health
"""
site = models.ForeignKey(
'igny8_core_auth.Site',
on_delete=models.CASCADE,
related_name='plugin_installations',
help_text="Site where plugin is installed"
)
plugin = models.ForeignKey(
Plugin,
on_delete=models.CASCADE,
related_name='installations',
help_text="Installed plugin"
)
current_version = models.ForeignKey(
PluginVersion,
on_delete=models.SET_NULL,
null=True,
related_name='current_installations',
help_text="Currently installed version"
)
# Installation status
is_active = models.BooleanField(
default=True,
db_index=True,
help_text="Whether the plugin is currently active"
)
last_health_check = models.DateTimeField(
null=True,
blank=True,
help_text="Last successful health check timestamp"
)
health_status = models.CharField(
max_length=20,
default='unknown',
choices=[
('healthy', 'Healthy'),
('outdated', 'Outdated'),
('error', 'Error'),
('unknown', 'Unknown'),
],
help_text="Current health status"
)
# Update tracking
pending_update = models.ForeignKey(
PluginVersion,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='pending_installations',
help_text="Pending version update"
)
update_notified_at = models.DateTimeField(
null=True,
blank=True,
help_text="When site was notified about pending update"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'plugin_installations'
unique_together = ['site', 'plugin']
ordering = ['-updated_at']
verbose_name = 'Plugin Installation'
verbose_name_plural = 'Plugin Installations'
def __str__(self):
version_str = f" v{self.current_version.version}" if self.current_version else ""
return f"{self.plugin.name}{version_str} on {self.site.name}"
def check_for_update(self):
"""Check if an update is available for this installation."""
latest = self.plugin.get_latest_version()
if not latest or not self.current_version:
return None
if latest.version_code > self.current_version.version_code:
return latest
return None
def update_health_status(self):
"""Update health status based on current state."""
latest = self.plugin.get_latest_version()
if not self.current_version:
self.health_status = 'unknown'
elif latest and latest.version_code > self.current_version.version_code:
self.health_status = 'outdated'
else:
self.health_status = 'healthy'
self.last_health_check = timezone.now()
self.save(update_fields=['health_status', 'last_health_check'])
class PluginDownload(models.Model):
"""
Tracks plugin download events for analytics.
"""
plugin = models.ForeignKey(
Plugin,
on_delete=models.CASCADE,
related_name='downloads'
)
version = models.ForeignKey(
PluginVersion,
on_delete=models.CASCADE,
related_name='downloads'
)
# Download context
site = models.ForeignKey(
'igny8_core_auth.Site',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='plugin_downloads',
help_text="Site that initiated the download (if authenticated)"
)
account = models.ForeignKey(
'igny8_core_auth.Account',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='plugin_downloads',
help_text="Account that initiated the download"
)
# Request info
ip_address = models.GenericIPAddressField(
null=True,
blank=True
)
user_agent = models.CharField(
max_length=500,
blank=True
)
# Download type
download_type = models.CharField(
max_length=20,
default='manual',
choices=[
('manual', 'Manual Download'),
('update', 'Auto Update'),
('api', 'API Download'),
]
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'plugin_downloads'
ordering = ['-created_at']
indexes = [
models.Index(fields=['plugin', 'created_at']),
models.Index(fields=['version', 'created_at']),
]
verbose_name = 'Plugin Download'
verbose_name_plural = 'Plugin Downloads'
def __str__(self):
return f"Download: {self.plugin.name} v{self.version.version}"

View File

@@ -0,0 +1,238 @@
"""
Plugin Distribution System Serializers
"""
from rest_framework import serializers
from .models import Plugin, PluginVersion, PluginInstallation, PluginDownload
class PluginVersionSerializer(serializers.ModelSerializer):
"""Serializer for plugin versions."""
download_count = serializers.SerializerMethodField()
class Meta:
model = PluginVersion
fields = [
'id',
'version',
'version_code',
'status',
'file_size',
'checksum',
'changelog',
'min_api_version',
'min_platform_version',
'min_php_version',
'force_update',
'released_at',
'created_at',
'download_count',
]
read_only_fields = ['id', 'version_code', 'created_at', 'download_count']
def get_download_count(self, obj):
return obj.get_download_count()
class PluginSerializer(serializers.ModelSerializer):
"""Serializer for plugins."""
latest_version = serializers.SerializerMethodField()
download_count = serializers.SerializerMethodField()
class Meta:
model = Plugin
fields = [
'id',
'name',
'slug',
'platform',
'description',
'homepage_url',
'is_active',
'latest_version',
'download_count',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at']
def get_latest_version(self, obj):
latest = obj.get_latest_version()
if latest:
return {
'version': latest.version,
'version_code': latest.version_code,
'released_at': latest.released_at,
'changelog': latest.changelog,
}
return None
def get_download_count(self, obj):
return obj.get_download_count()
class PluginDetailSerializer(PluginSerializer):
"""Detailed serializer for plugins including versions."""
versions = PluginVersionSerializer(many=True, read_only=True)
class Meta(PluginSerializer.Meta):
fields = PluginSerializer.Meta.fields + ['versions']
class PluginInfoSerializer(serializers.Serializer):
"""
Serializer for WordPress plugin info endpoint.
Returns data in WordPress-compatible format.
"""
name = serializers.CharField()
slug = serializers.CharField()
version = serializers.CharField()
author = serializers.CharField(default='IGNY8')
homepage = serializers.URLField()
description = serializers.CharField()
changelog = serializers.CharField()
download_url = serializers.URLField()
file_size = serializers.IntegerField()
requires_php = serializers.CharField()
tested_wp = serializers.CharField(default='6.7')
class CheckUpdateSerializer(serializers.Serializer):
"""Serializer for update check response."""
update_available = serializers.BooleanField()
current_version = serializers.CharField()
latest_version = serializers.CharField(allow_null=True)
latest_version_code = serializers.IntegerField(allow_null=True)
download_url = serializers.URLField(allow_null=True)
changelog = serializers.CharField(allow_null=True)
info_url = serializers.URLField(allow_null=True)
force_update = serializers.BooleanField(default=False)
checksum = serializers.CharField(allow_null=True)
class RegisterInstallationSerializer(serializers.Serializer):
"""Serializer for registering plugin installations."""
site_id = serializers.IntegerField()
version = serializers.CharField()
def validate_site_id(self, value):
from igny8_core.auth.models import Site
try:
Site.objects.get(id=value)
except Site.DoesNotExist:
raise serializers.ValidationError("Site not found")
return value
class HealthCheckSerializer(serializers.Serializer):
"""Serializer for plugin health check."""
site_id = serializers.IntegerField()
version = serializers.CharField()
status = serializers.ChoiceField(choices=['active', 'inactive', 'error'])
error_message = serializers.CharField(required=False, allow_blank=True)
class PluginInstallationSerializer(serializers.ModelSerializer):
"""Serializer for plugin installations."""
plugin_name = serializers.CharField(source='plugin.name', read_only=True)
plugin_slug = serializers.CharField(source='plugin.slug', read_only=True)
current_version_str = serializers.CharField(source='current_version.version', read_only=True)
site_name = serializers.CharField(source='site.name', read_only=True)
pending_update_version = serializers.SerializerMethodField()
class Meta:
model = PluginInstallation
fields = [
'id',
'site',
'site_name',
'plugin',
'plugin_name',
'plugin_slug',
'current_version',
'current_version_str',
'is_active',
'last_health_check',
'health_status',
'pending_update',
'pending_update_version',
'update_notified_at',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at']
def get_pending_update_version(self, obj):
if obj.pending_update:
return obj.pending_update.version
return None
class PluginDownloadSerializer(serializers.ModelSerializer):
"""Serializer for plugin downloads."""
plugin_name = serializers.CharField(source='plugin.name', read_only=True)
version_str = serializers.CharField(source='version.version', read_only=True)
class Meta:
model = PluginDownload
fields = [
'id',
'plugin',
'plugin_name',
'version',
'version_str',
'site',
'account',
'download_type',
'created_at',
]
read_only_fields = ['id', 'created_at']
# Admin Serializers
class AdminPluginVersionCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating new plugin versions (admin)."""
class Meta:
model = PluginVersion
fields = [
'version',
'status',
'changelog',
'min_api_version',
'min_platform_version',
'min_php_version',
'force_update',
]
def validate_version(self, value):
"""Ensure version follows semantic versioning."""
import re
if not re.match(r'^\d+\.\d+\.\d+$', value):
raise serializers.ValidationError(
"Version must follow semantic versioning (e.g., 1.0.0)"
)
return value
class AdminPluginVersionUploadSerializer(serializers.Serializer):
"""Serializer for uploading plugin ZIP file."""
file = serializers.FileField()
def validate_file(self, value):
"""Ensure uploaded file is a ZIP."""
if not value.name.endswith('.zip'):
raise serializers.ValidationError("File must be a ZIP archive")
# Check file size (max 50MB)
max_size = 50 * 1024 * 1024
if value.size > max_size:
raise serializers.ValidationError(
f"File too large. Maximum size is {max_size / 1024 / 1024}MB"
)
return value

View File

@@ -0,0 +1,62 @@
"""
Plugin Distribution System URL Routes
"""
from django.urls import path
from . import views
app_name = 'plugins'
urlpatterns = [
# ============================================================================
# Public Endpoints (No Auth Required)
# ============================================================================
# Download latest version of a plugin
path('<slug:slug>/download/', views.download_plugin, name='download'),
# Check for updates (called by installed plugins)
path('<slug:slug>/check-update/', views.check_update, name='check-update'),
# Get plugin information (WordPress compatible)
path('<slug:slug>/info/', views.plugin_info, name='info'),
# Get latest plugin for a platform
path('<str:platform>/latest/', views.get_latest_plugin, name='latest-by-platform'),
# ============================================================================
# Authenticated Endpoints
# ============================================================================
# Register plugin installation
path('<slug:slug>/register-installation/', views.register_installation, name='register-installation'),
# Report health status
path('<slug:slug>/health-check/', views.health_check, name='health-check'),
# Track download (for analytics)
path('<slug:slug>/track-download/', views.track_download, name='track-download'),
]
# Admin URL patterns (to be included under /api/admin/plugins/)
admin_urlpatterns = [
# List all plugins / Create new plugin
path('', views.AdminPluginListView.as_view(), name='admin-plugin-list'),
# Plugin versions management
path('<slug:slug>/versions/', views.AdminPluginVersionsView.as_view(), name='admin-versions'),
path('<slug:slug>/versions/<str:version>/', views.AdminPluginVersionDetailView.as_view(), name='admin-version-detail'),
# Release a version
path('<slug:slug>/versions/<str:version>/release/', views.admin_release_version, name='admin-release'),
# Push update to installations
path('<slug:slug>/versions/<str:version>/push-update/', views.admin_push_update, name='admin-push-update'),
# View installations
path('installations/', views.AdminPluginInstallationsView.as_view(), name='admin-all-installations'),
path('<slug:slug>/installations/', views.AdminPluginInstallationsView.as_view(), name='admin-installations'),
# Statistics
path('stats/', views.AdminPluginStatsView.as_view(), name='admin-all-stats'),
path('<slug:slug>/stats/', views.AdminPluginStatsView.as_view(), name='admin-stats'),
]

View File

@@ -0,0 +1,321 @@
"""
Plugin Distribution System Utilities
Helper functions for plugin management, ZIP creation, and versioning.
"""
import os
import hashlib
import zipfile
import shutil
import logging
from pathlib import Path
from typing import Optional, Tuple
from django.conf import settings
logger = logging.getLogger(__name__)
# Base paths for plugin storage
PLUGINS_ROOT = Path('/data/app/igny8/plugins')
WORDPRESS_SOURCE = PLUGINS_ROOT / 'wordpress' / 'source'
WORDPRESS_DIST = PLUGINS_ROOT / 'wordpress' / 'dist'
SHOPIFY_SOURCE = PLUGINS_ROOT / 'shopify' / 'source'
SHOPIFY_DIST = PLUGINS_ROOT / 'shopify' / 'dist'
CUSTOM_SOURCE = PLUGINS_ROOT / 'custom-site' / 'source'
CUSTOM_DIST = PLUGINS_ROOT / 'custom-site' / 'dist'
PLATFORM_PATHS = {
'wordpress': {
'source': WORDPRESS_SOURCE,
'dist': WORDPRESS_DIST,
},
'shopify': {
'source': SHOPIFY_SOURCE,
'dist': SHOPIFY_DIST,
},
'custom': {
'source': CUSTOM_SOURCE,
'dist': CUSTOM_DIST,
},
}
def get_source_path(platform: str, plugin_slug: str) -> Path:
"""Get the source directory path for a plugin."""
paths = PLATFORM_PATHS.get(platform)
if not paths:
raise ValueError(f"Unknown platform: {platform}")
return paths['source'] / plugin_slug
def get_dist_path(platform: str) -> Path:
"""Get the distribution directory path for a platform."""
paths = PLATFORM_PATHS.get(platform)
if not paths:
raise ValueError(f"Unknown platform: {platform}")
return paths['dist']
def calculate_checksum(file_path: str) -> str:
"""Calculate SHA256 checksum of a file."""
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256_hash.update(chunk)
return sha256_hash.hexdigest()
def parse_version_to_code(version_string: str) -> int:
"""
Convert version string to numeric code for comparison.
'1.0.0' -> 10000
'1.0.1' -> 10001
'1.2.3' -> 10203
'2.0.0' -> 20000
"""
try:
parts = version_string.split('.')
major = int(parts[0]) if len(parts) > 0 else 0
minor = int(parts[1]) if len(parts) > 1 else 0
patch = int(parts[2]) if len(parts) > 2 else 0
return major * 10000 + minor * 100 + patch
except (ValueError, IndexError):
return 0
def code_to_version(version_code: int) -> str:
"""Convert version code back to version string."""
major = version_code // 10000
minor = (version_code % 10000) // 100
patch = version_code % 100
return f"{major}.{minor}.{patch}"
def update_php_version(file_path: str, new_version: str) -> bool:
"""
Update version numbers in a PHP plugin file.
Updates both the plugin header and any $version variables.
"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
import re
# Update "Version: X.X.X" in plugin header
content = re.sub(
r'(Version:\s*)\d+\.\d+\.\d+',
f'\\g<1>{new_version}',
content
)
# Update IGNY8_BRIDGE_VERSION constant
content = re.sub(
r"(define\s*\(\s*'IGNY8_BRIDGE_VERSION'\s*,\s*')[^']+(')",
f"\\g<1>{new_version}\\g<2>",
content
)
# Update $version variable
content = re.sub(
r"(\$version\s*=\s*')[^']+(')",
f"\\g<1>{new_version}\\g<2>",
content
)
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
return True
except Exception as e:
logger.error(f"Failed to update PHP version: {e}")
return False
def create_plugin_zip(
platform: str,
plugin_slug: str,
version: str,
update_version: bool = True
) -> Tuple[Optional[str], Optional[str], Optional[int]]:
"""
Create a ZIP file for plugin distribution.
Args:
platform: Target platform ('wordpress', 'shopify', 'custom')
plugin_slug: Plugin slug (e.g., 'igny8-wp-bridge')
version: Version string (e.g., '1.0.1')
update_version: Whether to update version numbers in source files
Returns:
Tuple of (file_path, checksum, file_size) or (None, None, None) on error
"""
try:
source_path = get_source_path(platform, plugin_slug)
dist_path = get_dist_path(platform)
if not source_path.exists():
logger.error(f"Source path does not exist: {source_path}")
return None, None, None
# Ensure dist directory exists
dist_path.mkdir(parents=True, exist_ok=True)
# Create temp directory for packaging
import tempfile
temp_dir = tempfile.mkdtemp()
package_dir = Path(temp_dir) / plugin_slug
try:
# Copy source files to temp directory
shutil.copytree(source_path, package_dir)
# Update version in main plugin file if requested
if update_version and platform == 'wordpress':
main_file = package_dir / f"{plugin_slug}.php"
# Handle renamed plugin file
if not main_file.exists():
main_file = package_dir / "igny8-bridge.php"
if main_file.exists():
update_php_version(str(main_file), version)
# Remove unwanted files
patterns_to_remove = [
'**/__pycache__',
'**/*.pyc',
'**/.git',
'**/.gitignore',
'**/tests',
'**/tester',
'**/.DS_Store',
]
for pattern in patterns_to_remove:
for path in package_dir.glob(pattern):
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
# Create ZIP file
zip_filename = f"{plugin_slug}-v{version}.zip"
zip_path = dist_path / zip_filename
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(package_dir):
for file in files:
file_path = Path(root) / file
arcname = file_path.relative_to(temp_dir)
zipf.write(file_path, arcname)
# Create/update latest symlink
latest_link = dist_path / f"{plugin_slug}-latest.zip"
if latest_link.exists() or latest_link.is_symlink():
latest_link.unlink()
latest_link.symlink_to(zip_filename)
# Calculate checksum and file size
checksum = calculate_checksum(str(zip_path))
file_size = zip_path.stat().st_size
logger.info(f"Created plugin ZIP: {zip_path} ({file_size} bytes)")
return zip_filename, checksum, file_size
finally:
# Clean up temp directory
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception as e:
logger.exception(f"Failed to create plugin ZIP: {e}")
return None, None, None
def get_plugin_file_path(platform: str, file_name: str) -> Optional[Path]:
"""Get the full path to a plugin ZIP file."""
dist_path = get_dist_path(platform)
file_path = dist_path / file_name
if file_path.exists():
return file_path
return None
def verify_checksum(file_path: str, expected_checksum: str) -> bool:
"""Verify a file's checksum matches expected value."""
actual_checksum = calculate_checksum(file_path)
return actual_checksum == expected_checksum
def get_installed_version_from_header(file_path: str) -> Optional[str]:
"""Extract version from a PHP plugin file header."""
try:
with open(file_path, 'r', encoding='utf-8') as f:
# Read first 8KB which should contain the header
content = f.read(8192)
import re
match = re.search(r'Version:\s*(\d+\.\d+\.\d+)', content)
if match:
return match.group(1)
return None
except Exception as e:
logger.error(f"Failed to extract version from {file_path}: {e}")
return None
def list_available_versions(platform: str, plugin_slug: str) -> list:
"""List all available versions for a plugin."""
dist_path = get_dist_path(platform)
versions = []
import re
pattern = re.compile(rf'^{re.escape(plugin_slug)}-v(\d+\.\d+\.\d+)\.zip$')
for file in dist_path.iterdir():
if file.is_file():
match = pattern.match(file.name)
if match:
versions.append({
'version': match.group(1),
'version_code': parse_version_to_code(match.group(1)),
'file_name': file.name,
'file_size': file.stat().st_size,
})
# Sort by version_code descending
versions.sort(key=lambda x: x['version_code'], reverse=True)
return versions
def cleanup_old_versions(platform: str, plugin_slug: str, keep_count: int = 5) -> int:
"""
Remove old version ZIP files, keeping the most recent ones.
Args:
platform: Target platform
plugin_slug: Plugin slug
keep_count: Number of recent versions to keep
Returns:
Number of versions removed
"""
versions = list_available_versions(platform, plugin_slug)
removed = 0
if len(versions) > keep_count:
versions_to_remove = versions[keep_count:]
dist_path = get_dist_path(platform)
for version_info in versions_to_remove:
file_path = dist_path / version_info['file_name']
try:
file_path.unlink()
removed += 1
logger.info(f"Removed old version: {file_path}")
except Exception as e:
logger.error(f"Failed to remove {file_path}: {e}")
return removed

View File

@@ -0,0 +1,658 @@
"""
Plugin Distribution System Views
API endpoints for plugin distribution, updates, and management.
"""
import logging
from django.http import FileResponse, Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny, IsAuthenticated, IsAdminUser
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Plugin, PluginVersion, PluginInstallation, PluginDownload
from .serializers import (
PluginSerializer,
PluginDetailSerializer,
PluginVersionSerializer,
PluginInfoSerializer,
CheckUpdateSerializer,
RegisterInstallationSerializer,
HealthCheckSerializer,
PluginInstallationSerializer,
AdminPluginVersionCreateSerializer,
AdminPluginVersionUploadSerializer,
)
from .utils import get_plugin_file_path, parse_version_to_code
logger = logging.getLogger(__name__)
# ============================================================================
# Public Endpoints (No Auth Required)
# ============================================================================
@api_view(['GET'])
@permission_classes([AllowAny])
def download_plugin(request, slug):
"""
Download the latest version of a plugin.
GET /api/plugins/{slug}/download/
Returns the ZIP file for the latest released version.
"""
plugin = get_object_or_404(Plugin, slug=slug, is_active=True)
latest = plugin.get_latest_version()
if not latest:
return Response(
{'error': 'No version available for download'},
status=status.HTTP_404_NOT_FOUND
)
file_path = get_plugin_file_path(plugin.platform, latest.file_path)
if not file_path or not file_path.exists():
logger.error(f"Plugin file not found: {latest.file_path}")
return Response(
{'error': 'Plugin file not found'},
status=status.HTTP_404_NOT_FOUND
)
# Track download
PluginDownload.objects.create(
plugin=plugin,
version=latest,
ip_address=get_client_ip(request),
user_agent=request.META.get('HTTP_USER_AGENT', '')[:500],
download_type='manual',
)
response = FileResponse(
open(file_path, 'rb'),
content_type='application/zip'
)
response['Content-Disposition'] = f'attachment; filename="{latest.file_path}"'
response['Content-Length'] = latest.file_size
return response
@api_view(['GET'])
@permission_classes([AllowAny])
def check_update(request, slug):
"""
Check if an update is available for a plugin.
GET /api/plugins/{slug}/check-update/?current_version=1.0.0
Called by installed plugins to check for updates.
Returns update availability and download URL if available.
"""
plugin = get_object_or_404(Plugin, slug=slug, is_active=True)
current_version = request.query_params.get('current_version', '0.0.0')
current_code = parse_version_to_code(current_version)
latest = plugin.get_latest_version()
if not latest:
return Response({
'update_available': False,
'current_version': current_version,
'latest_version': None,
'latest_version_code': None,
'download_url': None,
'changelog': None,
'info_url': None,
'force_update': False,
'checksum': None,
})
update_available = latest.version_code > current_code
# Build download URL
download_url = None
if update_available:
download_url = request.build_absolute_uri(f'/api/plugins/{slug}/download/')
response_data = {
'update_available': update_available,
'current_version': current_version,
'latest_version': latest.version if update_available else None,
'latest_version_code': latest.version_code if update_available else None,
'download_url': download_url,
'changelog': latest.changelog if update_available else None,
'info_url': request.build_absolute_uri(f'/api/plugins/{slug}/info/'),
'force_update': latest.force_update if update_available else False,
'checksum': latest.checksum if update_available else None,
}
serializer = CheckUpdateSerializer(response_data)
return Response(serializer.data)
@api_view(['GET'])
@permission_classes([AllowAny])
def plugin_info(request, slug):
"""
Get plugin information in WordPress-compatible format.
GET /api/plugins/{slug}/info/
Returns detailed plugin information for WordPress plugin info modal.
"""
plugin = get_object_or_404(Plugin, slug=slug, is_active=True)
latest = plugin.get_latest_version()
if not latest:
return Response(
{'error': 'No version available'},
status=status.HTTP_404_NOT_FOUND
)
info_data = {
'name': plugin.name,
'slug': plugin.slug,
'version': latest.version,
'author': 'IGNY8',
'homepage': plugin.homepage_url or 'https://igny8.com',
'description': plugin.description,
'changelog': latest.changelog,
'download_url': request.build_absolute_uri(f'/api/plugins/{slug}/download/'),
'file_size': latest.file_size,
'requires_php': latest.min_php_version,
'tested_wp': '6.7',
}
serializer = PluginInfoSerializer(info_data)
return Response(serializer.data)
@api_view(['GET'])
@permission_classes([AllowAny])
def get_latest_plugin(request, platform):
"""
Get the latest plugin for a platform.
GET /api/plugins/{platform}/latest/
Returns plugin info and download URL for the specified platform.
"""
valid_platforms = ['wordpress', 'shopify', 'custom']
if platform not in valid_platforms:
return Response(
{'error': f'Invalid platform. Must be one of: {valid_platforms}'},
status=status.HTTP_400_BAD_REQUEST
)
plugin = Plugin.objects.filter(platform=platform, is_active=True).first()
if not plugin:
return Response(
{'error': f'No plugin available for platform: {platform}'},
status=status.HTTP_404_NOT_FOUND
)
latest = plugin.get_latest_version()
if not latest:
return Response(
{'error': 'No version available'},
status=status.HTTP_404_NOT_FOUND
)
return Response({
'name': plugin.name,
'slug': plugin.slug,
'version': latest.version,
'download_url': request.build_absolute_uri(f'/api/plugins/{plugin.slug}/download/'),
'file_size': latest.file_size,
'platform': plugin.platform,
'description': plugin.description,
'changelog': latest.changelog,
'released_at': latest.released_at,
})
# ============================================================================
# Authenticated Endpoints
# ============================================================================
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def register_installation(request, slug):
"""
Register a plugin installation on a site.
POST /api/plugins/{slug}/register-installation/
Body: { "site_id": 123, "version": "1.0.0" }
Called after plugin is activated on a site.
"""
plugin = get_object_or_404(Plugin, slug=slug, is_active=True)
serializer = RegisterInstallationSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
from igny8_core.auth.models import Site
site_id = serializer.validated_data['site_id']
version_str = serializer.validated_data['version']
# Verify site belongs to user's account
site = get_object_or_404(Site, id=site_id)
if hasattr(request, 'account') and site.account_id != request.account.id:
return Response(
{'error': 'Site does not belong to your account'},
status=status.HTTP_403_FORBIDDEN
)
# Find version record
try:
version = PluginVersion.objects.get(plugin=plugin, version=version_str)
except PluginVersion.DoesNotExist:
version = None
# Create or update installation record
installation, created = PluginInstallation.objects.update_or_create(
site=site,
plugin=plugin,
defaults={
'current_version': version,
'is_active': True,
'last_health_check': timezone.now(),
'health_status': 'healthy',
}
)
action = 'registered' if created else 'updated'
logger.info(f"Plugin installation {action}: {plugin.name} v{version_str} on site {site.name}")
return Response({
'success': True,
'message': f'Installation {action} successfully',
'installation_id': installation.id,
})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def health_check(request, slug):
"""
Report plugin health status.
POST /api/plugins/{slug}/health-check/
Body: { "site_id": 123, "version": "1.0.0", "status": "active" }
Called periodically by installed plugins to report status.
"""
plugin = get_object_or_404(Plugin, slug=slug, is_active=True)
serializer = HealthCheckSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
from igny8_core.auth.models import Site
site_id = serializer.validated_data['site_id']
version_str = serializer.validated_data['version']
plugin_status = serializer.validated_data['status']
site = get_object_or_404(Site, id=site_id)
try:
installation = PluginInstallation.objects.get(site=site, plugin=plugin)
except PluginInstallation.DoesNotExist:
# Auto-register if not exists
try:
version = PluginVersion.objects.get(plugin=plugin, version=version_str)
except PluginVersion.DoesNotExist:
version = None
installation = PluginInstallation.objects.create(
site=site,
plugin=plugin,
current_version=version,
is_active=plugin_status == 'active',
)
# Update installation status
installation.last_health_check = timezone.now()
installation.is_active = plugin_status == 'active'
if plugin_status == 'error':
installation.health_status = 'error'
else:
installation.update_health_status()
installation.save()
# Check if update is available
update_available = installation.check_for_update()
return Response({
'success': True,
'health_status': installation.health_status,
'update_available': update_available is not None,
'latest_version': update_available.version if update_available else None,
})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def track_download(request, slug):
"""
Track a plugin download for analytics.
POST /api/plugins/{slug}/track-download/
Called before redirecting to download URL.
"""
plugin = get_object_or_404(Plugin, slug=slug, is_active=True)
latest = plugin.get_latest_version()
if not latest:
return Response(
{'error': 'No version available'},
status=status.HTTP_404_NOT_FOUND
)
# Get site if provided
site = None
site_id = request.data.get('site_id')
if site_id:
from igny8_core.auth.models import Site
try:
site = Site.objects.get(id=site_id)
except Site.DoesNotExist:
pass
# Get account from request
account = getattr(request, 'account', None)
# Track download
PluginDownload.objects.create(
plugin=plugin,
version=latest,
site=site,
account=account,
ip_address=get_client_ip(request),
user_agent=request.META.get('HTTP_USER_AGENT', '')[:500],
download_type='manual',
)
return Response({
'success': True,
'download_url': request.build_absolute_uri(f'/api/plugins/{slug}/download/'),
})
# ============================================================================
# Admin Endpoints
# ============================================================================
class AdminPluginListView(APIView):
"""List all plugins and their versions (admin)."""
permission_classes = [IsAdminUser]
def get(self, request):
plugins = Plugin.objects.prefetch_related('versions').all()
serializer = PluginDetailSerializer(plugins, many=True)
return Response(serializer.data)
def post(self, request):
"""Create a new plugin."""
serializer = PluginSerializer(data=request.data)
if serializer.is_valid():
plugin = serializer.save()
return Response(
PluginSerializer(plugin).data,
status=status.HTTP_201_CREATED
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class AdminPluginVersionsView(APIView):
"""Manage plugin versions (admin)."""
permission_classes = [IsAdminUser]
def get(self, request, slug):
"""List all versions for a plugin."""
plugin = get_object_or_404(Plugin, slug=slug)
versions = plugin.versions.all()
serializer = PluginVersionSerializer(versions, many=True)
return Response(serializer.data)
def post(self, request, slug):
"""Create a new version for a plugin."""
plugin = get_object_or_404(Plugin, slug=slug)
serializer = AdminPluginVersionCreateSerializer(data=request.data)
if serializer.is_valid():
# Check if version already exists
version_str = serializer.validated_data['version']
if PluginVersion.objects.filter(plugin=plugin, version=version_str).exists():
return Response(
{'error': f'Version {version_str} already exists'},
status=status.HTTP_400_BAD_REQUEST
)
version = serializer.save(plugin=plugin)
return Response(
PluginVersionSerializer(version).data,
status=status.HTTP_201_CREATED
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class AdminPluginVersionDetailView(APIView):
"""Manage a specific plugin version (admin)."""
permission_classes = [IsAdminUser]
def get(self, request, slug, version):
"""Get version details."""
plugin = get_object_or_404(Plugin, slug=slug)
version_obj = get_object_or_404(PluginVersion, plugin=plugin, version=version)
serializer = PluginVersionSerializer(version_obj)
return Response(serializer.data)
def patch(self, request, slug, version):
"""Update version details."""
plugin = get_object_or_404(Plugin, slug=slug)
version_obj = get_object_or_404(PluginVersion, plugin=plugin, version=version)
serializer = PluginVersionSerializer(version_obj, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, version):
"""Delete a version (only drafts)."""
plugin = get_object_or_404(Plugin, slug=slug)
version_obj = get_object_or_404(PluginVersion, plugin=plugin, version=version)
if version_obj.status not in ['draft', 'testing']:
return Response(
{'error': 'Can only delete draft or testing versions'},
status=status.HTTP_400_BAD_REQUEST
)
version_obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['POST'])
@permission_classes([IsAdminUser])
def admin_release_version(request, slug, version):
"""
Release a plugin version.
POST /api/admin/plugins/{slug}/versions/{version}/release/
Makes the version available for download.
"""
plugin = get_object_or_404(Plugin, slug=slug)
version_obj = get_object_or_404(PluginVersion, plugin=plugin, version=version)
if version_obj.status in ['released', 'update_ready']:
return Response(
{'error': 'Version is already released'},
status=status.HTTP_400_BAD_REQUEST
)
if not version_obj.file_path:
return Response(
{'error': 'No file uploaded for this version'},
status=status.HTTP_400_BAD_REQUEST
)
version_obj.release()
logger.info(f"Released plugin version: {plugin.name} v{version}")
return Response({
'success': True,
'message': f'Version {version} released successfully',
'released_at': version_obj.released_at,
})
@api_view(['POST'])
@permission_classes([IsAdminUser])
def admin_push_update(request, slug, version):
"""
Push update to all installed sites.
POST /api/admin/plugins/{slug}/versions/{version}/push-update/
Sets status to 'update_ready' and notifies installations.
"""
plugin = get_object_or_404(Plugin, slug=slug)
version_obj = get_object_or_404(PluginVersion, plugin=plugin, version=version)
if version_obj.status not in ['released', 'update_ready']:
return Response(
{'error': 'Version must be released before pushing update'},
status=status.HTTP_400_BAD_REQUEST
)
# Mark as update ready
version_obj.status = 'update_ready'
version_obj.save(update_fields=['status'])
# Update all installations with pending update
installations = PluginInstallation.objects.filter(
plugin=plugin,
is_active=True
).exclude(current_version=version_obj)
updated_count = installations.update(
pending_update=version_obj,
update_notified_at=timezone.now()
)
logger.info(f"Pushed update {plugin.name} v{version} to {updated_count} installations")
return Response({
'success': True,
'message': f'Update pushed to {updated_count} installations',
'installations_notified': updated_count,
})
class AdminPluginInstallationsView(APIView):
"""View plugin installations (admin)."""
permission_classes = [IsAdminUser]
def get(self, request, slug=None):
"""List installations, optionally filtered by plugin."""
if slug:
plugin = get_object_or_404(Plugin, slug=slug)
installations = PluginInstallation.objects.filter(plugin=plugin)
else:
installations = PluginInstallation.objects.all()
installations = installations.select_related(
'site', 'plugin', 'current_version', 'pending_update'
)
serializer = PluginInstallationSerializer(installations, many=True)
return Response(serializer.data)
class AdminPluginStatsView(APIView):
"""Get plugin statistics (admin)."""
permission_classes = [IsAdminUser]
def get(self, request, slug=None):
"""Get download and installation statistics."""
from django.db.models import Count
from django.db.models.functions import TruncDate
plugins_qs = Plugin.objects.all()
if slug:
plugins_qs = plugins_qs.filter(slug=slug)
stats = []
for plugin in plugins_qs:
# Version distribution
version_dist = PluginInstallation.objects.filter(
plugin=plugin,
is_active=True
).values('current_version__version').annotate(
count=Count('id')
).order_by('-count')
# Download counts by day (last 30 days)
from datetime import timedelta
thirty_days_ago = timezone.now() - timedelta(days=30)
daily_downloads = PluginDownload.objects.filter(
plugin=plugin,
created_at__gte=thirty_days_ago
).annotate(
date=TruncDate('created_at')
).values('date').annotate(
count=Count('id')
).order_by('date')
# Health status distribution
health_dist = PluginInstallation.objects.filter(
plugin=plugin
).values('health_status').annotate(
count=Count('id')
)
stats.append({
'plugin': plugin.slug,
'name': plugin.name,
'total_downloads': plugin.get_download_count(),
'active_installations': PluginInstallation.objects.filter(
plugin=plugin, is_active=True
).count(),
'version_distribution': list(version_dist),
'daily_downloads': list(daily_downloads),
'health_distribution': list(health_dist),
})
if slug and stats:
return Response(stats[0])
return Response(stats)
# ============================================================================
# Helper Functions
# ============================================================================
def get_client_ip(request):
"""Extract client IP from request."""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0].strip()
else:
ip = request.META.get('REMOTE_ADDR')
return ip