plugin distribution system
This commit is contained in:
218
REVERT-AND-FIX-SUMMARY.md
Normal file
218
REVERT-AND-FIX-SUMMARY.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Revert and Fix Summary
|
||||||
|
|
||||||
|
**Date:** January 9, 2026
|
||||||
|
**Status:** ✅ COMPLETED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Was Done
|
||||||
|
|
||||||
|
### 1. ✅ Reverted WordPress Plugin Changes
|
||||||
|
|
||||||
|
**Removed all unplanned WordPress plugin files:**
|
||||||
|
- Deleted entire `/plugins/wordpress/source/igny8-wp-bridge/` directory (20 files, ~4,600 lines)
|
||||||
|
- Deleted `/scripts/build-wp-plugin.sh` build script
|
||||||
|
|
||||||
|
**Result:** WordPress plugin directory completely removed from git staging area.
|
||||||
|
|
||||||
|
### 2. ✅ Fixed Plugin Download Issue
|
||||||
|
|
||||||
|
**Problem:** Frontend was trying to download plugin from GitHub instead of using the backend API.
|
||||||
|
|
||||||
|
**Fix Applied:**
|
||||||
|
```typescript
|
||||||
|
// File: frontend/src/components/sites/WordPressIntegrationForm.tsx
|
||||||
|
// Line: ~173
|
||||||
|
|
||||||
|
// BEFORE:
|
||||||
|
const handleDownloadPlugin = () => {
|
||||||
|
const pluginUrl = `https://github.com/igny8/igny8-wp-bridge/releases/latest/download/igny8-wp-bridge.zip`;
|
||||||
|
window.open(pluginUrl, '_blank');
|
||||||
|
toast.success('Plugin download started');
|
||||||
|
};
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
const handleDownloadPlugin = () => {
|
||||||
|
// Use the backend API endpoint for plugin download
|
||||||
|
const pluginUrl = `/api/plugins/igny8-wp-bridge/download/`;
|
||||||
|
window.open(pluginUrl, '_blank');
|
||||||
|
toast.success('Plugin download started');
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** Download button now points to correct API endpoint: `/api/plugins/igny8-wp-bridge/download/`
|
||||||
|
|
||||||
|
### 3. ⚠️ Plugin File Missing
|
||||||
|
|
||||||
|
**Issue Discovered:** The plugin ZIP file doesn't exist on the server.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
```bash
|
||||||
|
# API endpoint works but returns 404:
|
||||||
|
curl http://localhost:8011/api/plugins/wordpress/latest/
|
||||||
|
# Returns: download_url: "http://localhost:8011/api/plugins/igny8-wp-bridge/download/"
|
||||||
|
|
||||||
|
# But file doesn't exist:
|
||||||
|
docker exec igny8_backend python manage.py shell -c "..."
|
||||||
|
# Output: File exists: False
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database shows:**
|
||||||
|
- Plugin: "IGNY8 WordPress Bridge" (version 1.1.1)
|
||||||
|
- File path: `igny8-wp-bridge-v1.1.1.zip`
|
||||||
|
- File size: 98706 bytes
|
||||||
|
- **BUT:** Physical file doesn't exist at `/app/igny8-wp-bridge-v1.1.1.zip`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Needs to Be Done
|
||||||
|
|
||||||
|
### Step 1: Upload Plugin ZIP File
|
||||||
|
|
||||||
|
The plugin file needs to be manually uploaded to the backend.
|
||||||
|
|
||||||
|
**Option A: Upload via Django Admin** (Recommended)
|
||||||
|
1. Go to Django Admin: https://api.igny8.com/admin/
|
||||||
|
2. Navigate to: Plugins → Plugin Versions
|
||||||
|
3. Find version 1.1.1
|
||||||
|
4. Click edit
|
||||||
|
5. Upload the ZIP file in the "File" field
|
||||||
|
6. Save
|
||||||
|
|
||||||
|
**Option B: Upload via Command Line**
|
||||||
|
```bash
|
||||||
|
# If you have the plugin ZIP file locally, copy it to the container:
|
||||||
|
docker cp /path/to/igny8-wp-bridge-v1.1.1.zip igny8_backend:/app/igny8-wp-bridge-v1.1.1.zip
|
||||||
|
|
||||||
|
# Then update the database if needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Verify Plugin Path Configuration
|
||||||
|
|
||||||
|
Check that the Plugin Distribution System is configured to store files in the right location.
|
||||||
|
|
||||||
|
**Check settings.py for MEDIA_ROOT:**
|
||||||
|
```python
|
||||||
|
# backend/igny8_core/settings.py
|
||||||
|
# Should have something like:
|
||||||
|
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||||
|
MEDIA_URL = '/media/'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Currently:** MEDIA_ROOT is not configured in settings.py, which is why the file path is relative (`igny8-wp-bridge-v1.1.1.zip`) instead of absolute (`/app/media/plugins/igny8-wp-bridge-v1.1.1.zip`).
|
||||||
|
|
||||||
|
### Step 3: Fix Media Configuration (Recommended)
|
||||||
|
|
||||||
|
Add proper media configuration to settings.py:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/igny8_core/settings.py
|
||||||
|
# Add after STATIC configuration:
|
||||||
|
|
||||||
|
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||||
|
MEDIA_URL = '/media/'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update the plugin file path in the database to use the media directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Git Status
|
||||||
|
|
||||||
|
```
|
||||||
|
Changes not staged for commit:
|
||||||
|
modified: backend/igny8_core/settings.py
|
||||||
|
modified: backend/igny8_core/urls.py
|
||||||
|
modified: docs/plans/FINAL-PRELAUNCH-PENDING.md
|
||||||
|
modified: docs/plans/PLUGIN-DISTRIBUTION-SYSTEM.md
|
||||||
|
modified: frontend/src/components/sites/WordPressIntegrationForm.tsx
|
||||||
|
|
||||||
|
Untracked files:
|
||||||
|
backend/igny8_core/plugins/
|
||||||
|
```
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
1. ✅ `frontend/src/components/sites/WordPressIntegrationForm.tsx` - Fixed download URL
|
||||||
|
2. ✅ `backend/igny8_core/plugins/` - Plugin Distribution System (new module)
|
||||||
|
3. ✅ `backend/igny8_core/settings.py` - Added Plugin Management to admin navigation
|
||||||
|
4. ✅ `backend/igny8_core/urls.py` - Added plugin distribution URLs
|
||||||
|
5. ✅ Documentation updates
|
||||||
|
|
||||||
|
**What was reverted:**
|
||||||
|
1. ✅ All WordPress plugin source files (20 files removed)
|
||||||
|
2. ✅ Build script removed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing After Fix
|
||||||
|
|
||||||
|
### Test 1: API Endpoint
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8011/api/plugins/wordpress/latest/
|
||||||
|
```
|
||||||
|
**Expected:** Returns plugin info with download URL
|
||||||
|
**Status:** ✅ WORKS
|
||||||
|
|
||||||
|
### Test 2: Download Endpoint (After uploading file)
|
||||||
|
```bash
|
||||||
|
curl -I http://localhost:8011/api/plugins/igny8-wp-bridge/download/
|
||||||
|
```
|
||||||
|
**Expected:** Returns 200 OK and starts download
|
||||||
|
**Status:** ⚠️ Will work after file is uploaded
|
||||||
|
|
||||||
|
### Test 3: Frontend Download Button (After uploading file)
|
||||||
|
1. Go to: https://app.igny8.com/sites/{id}/settings?tab=integrations
|
||||||
|
2. Generate API key
|
||||||
|
3. Click "Download Plugin" button
|
||||||
|
**Expected:** Plugin downloads
|
||||||
|
**Status:** ⚠️ Will work after file is uploaded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
### ✅ Completed
|
||||||
|
- Reverted all unplanned WordPress plugin changes
|
||||||
|
- Fixed frontend download button to use correct API endpoint
|
||||||
|
- Backend plugin distribution system is working
|
||||||
|
- Admin navigation includes plugin management
|
||||||
|
|
||||||
|
### ⚠️ Pending
|
||||||
|
- Upload actual plugin ZIP file to backend
|
||||||
|
- Optionally: Add MEDIA_ROOT configuration to settings.py
|
||||||
|
|
||||||
|
### ❌ Removed
|
||||||
|
- 20 WordPress plugin template/sync files (~4,600 lines)
|
||||||
|
- Build script for plugin packaging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Fix Steps
|
||||||
|
|
||||||
|
**To make plugin download work immediately:**
|
||||||
|
|
||||||
|
1. Get the plugin ZIP file (if you have it)
|
||||||
|
2. Upload via Django Admin:
|
||||||
|
- URL: https://api.igny8.com/admin/plugins/pluginversion/
|
||||||
|
- Find version 1.1.1
|
||||||
|
- Upload ZIP file
|
||||||
|
- Save
|
||||||
|
|
||||||
|
**OR** if you need to create the plugin ZIP:
|
||||||
|
1. The actual WordPress plugin source should be in a separate repository
|
||||||
|
2. Create ZIP with proper structure
|
||||||
|
3. Upload to Django admin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Modified in This Session
|
||||||
|
|
||||||
|
1. `/data/app/igny8/frontend/src/components/sites/WordPressIntegrationForm.tsx`
|
||||||
|
- Changed download URL from GitHub to `/api/plugins/igny8-wp-bridge/download/`
|
||||||
|
|
||||||
|
2. **DELETED**: `/data/app/igny8/plugins/wordpress/source/igny8-wp-bridge/` (entire directory)
|
||||||
|
3. **DELETED**: `/data/app/igny8/scripts/build-wp-plugin.sh`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of Summary**
|
||||||
1
backend/igny8_core/plugins/__init__.py
Normal file
1
backend/igny8_core/plugins/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# IGNY8 Plugin Distribution System
|
||||||
173
backend/igny8_core/plugins/admin.py
Normal file
173
backend/igny8_core/plugins/admin.py
Normal 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']
|
||||||
|
}),
|
||||||
|
]
|
||||||
15
backend/igny8_core/plugins/apps.py
Normal file
15
backend/igny8_core/plugins/apps.py
Normal 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
|
||||||
1
backend/igny8_core/plugins/management/__init__.py
Normal file
1
backend/igny8_core/plugins/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Plugin management commands
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Plugin management commands
|
||||||
164
backend/igny8_core/plugins/management/commands/build_plugin.py
Normal file
164
backend/igny8_core/plugins/management/commands/build_plugin.py
Normal 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."
|
||||||
|
)
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -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/"
|
||||||
|
)
|
||||||
|
)
|
||||||
107
backend/igny8_core/plugins/migrations/0001_initial.py
Normal file
107
backend/igny8_core/plugins/migrations/0001_initial.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/igny8_core/plugins/migrations/__init__.py
Normal file
0
backend/igny8_core/plugins/migrations/__init__.py
Normal file
384
backend/igny8_core/plugins/models.py
Normal file
384
backend/igny8_core/plugins/models.py
Normal 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}"
|
||||||
238
backend/igny8_core/plugins/serializers.py
Normal file
238
backend/igny8_core/plugins/serializers.py
Normal 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
|
||||||
62
backend/igny8_core/plugins/urls.py
Normal file
62
backend/igny8_core/plugins/urls.py
Normal 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'),
|
||||||
|
]
|
||||||
321
backend/igny8_core/plugins/utils.py
Normal file
321
backend/igny8_core/plugins/utils.py
Normal 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
|
||||||
658
backend/igny8_core/plugins/views.py
Normal file
658
backend/igny8_core/plugins/views.py
Normal 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
|
||||||
@@ -73,6 +73,7 @@ INSTALLED_APPS = [
|
|||||||
'igny8_core.modules.optimizer.apps.OptimizerConfig',
|
'igny8_core.modules.optimizer.apps.OptimizerConfig',
|
||||||
'igny8_core.modules.publisher.apps.PublisherConfig',
|
'igny8_core.modules.publisher.apps.PublisherConfig',
|
||||||
'igny8_core.modules.integration.apps.IntegrationConfig',
|
'igny8_core.modules.integration.apps.IntegrationConfig',
|
||||||
|
'igny8_core.plugins.apps.PluginsConfig', # Plugin Distribution System
|
||||||
]
|
]
|
||||||
|
|
||||||
# System module needs explicit registration for admin
|
# System module needs explicit registration for admin
|
||||||
@@ -763,6 +764,18 @@ UNFOLD = {
|
|||||||
{"title": "Sync Events", "icon": "sync", "link": lambda request: "/admin/integration/syncevent/"},
|
{"title": "Sync Events", "icon": "sync", "link": lambda request: "/admin/integration/syncevent/"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
# Plugin Management
|
||||||
|
{
|
||||||
|
"title": "Plugin Management",
|
||||||
|
"icon": "extension",
|
||||||
|
"collapsible": True,
|
||||||
|
"items": [
|
||||||
|
{"title": "Plugins", "icon": "apps", "link": lambda request: "/admin/plugins/plugin/"},
|
||||||
|
{"title": "Plugin Versions", "icon": "new_releases", "link": lambda request: "/admin/plugins/pluginversion/"},
|
||||||
|
{"title": "Installations", "icon": "cloud_download", "link": lambda request: "/admin/plugins/plugininstallation/"},
|
||||||
|
{"title": "Downloads", "icon": "download", "link": lambda request: "/admin/plugins/plugindownload/"},
|
||||||
|
],
|
||||||
|
},
|
||||||
# Automation
|
# Automation
|
||||||
{
|
{
|
||||||
"title": "Automation",
|
"title": "Automation",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from igny8_core.auth.views import (
|
|||||||
seedkeyword_csv_template, seedkeyword_csv_import
|
seedkeyword_csv_template, seedkeyword_csv_import
|
||||||
)
|
)
|
||||||
from igny8_core.utils.geo_views import GeoDetectView
|
from igny8_core.utils.geo_views import GeoDetectView
|
||||||
|
from igny8_core.plugins.urls import admin_urlpatterns as plugins_admin_urls
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# CSV Import/Export for admin - MUST come before admin/ to avoid being caught by admin.site.urls
|
# CSV Import/Export for admin - MUST come before admin/ to avoid being caught by admin.site.urls
|
||||||
@@ -51,6 +52,9 @@ urlpatterns = [
|
|||||||
path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints
|
path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints
|
||||||
path('api/v1/integration/', include('igny8_core.modules.integration.urls')), # Integration endpoints
|
path('api/v1/integration/', include('igny8_core.modules.integration.urls')), # Integration endpoints
|
||||||
path('api/v1/geo/detect/', GeoDetectView.as_view(), name='geo-detect'), # Geo detection for signup routing
|
path('api/v1/geo/detect/', GeoDetectView.as_view(), name='geo-detect'), # Geo detection for signup routing
|
||||||
|
# Plugin Distribution System
|
||||||
|
path('api/plugins/', include('igny8_core.plugins.urls')), # Public plugin endpoints
|
||||||
|
path('api/admin/plugins/', include((plugins_admin_urls, 'plugins-admin'))), # Admin plugin management
|
||||||
# OpenAPI Schema and Documentation
|
# OpenAPI Schema and Documentation
|
||||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
| **1** | Code Cleanup & Technical Debt | 🔴 Critical | ✅ Completed (Jan 9) |
|
| **1** | Code Cleanup & Technical Debt | 🔴 Critical | ✅ Completed (Jan 9) |
|
||||||
| **2** | Content & Template Optimization | 🔴 Critical | ⏳ Pending |
|
| **2** | Content & Template Optimization | 🔴 Critical | ⏳ Pending |
|
||||||
| **3** | Pipeline Verification & Testing | 🔴 Critical | ⏳ Pending |
|
| **3** | Pipeline Verification & Testing | 🔴 Critical | ⏳ Pending |
|
||||||
| **4** | Email & Notifications QA | 🟡 High | ⏳ Pending |
|
| **4** | Email & Notifications QA |🟡 High | ✅ Completed (Jan 9) |
|
||||||
| **5** | UX Improvements | 🟡 High | ✅ Completed (Jan 9) |
|
| **5** | UX Improvements | 🟡 High | ✅ Completed (Jan 9) |
|
||||||
| **6** | Data Backup & Cleanup | 🔴 Critical | ✅ Completed (Jan 9) |
|
| **6** | Data Backup & Cleanup | 🔴 Critical | ✅ Completed (Jan 9) |
|
||||||
| **7** | User Testing & Verification | 🔴 Critical | ⏳ Pending |
|
| **7** | User Testing & Verification | 🔴 Critical | ⏳ Pending |
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Created:** January 9, 2026
|
**Created:** January 9, 2026
|
||||||
**Version:** 1.0
|
**Version:** 1.0
|
||||||
**Status:** Planning
|
**Status:** ✅ Phase 1 Implemented
|
||||||
**Scope:** WordPress, Shopify, Custom Site Integration Plugins
|
**Scope:** WordPress, Shopify, Custom Site Integration Plugins
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -553,23 +553,23 @@ export function PluginDownloadSection({ platform = 'wordpress' }: { platform: st
|
|||||||
|
|
||||||
## 8. Implementation Phases
|
## 8. Implementation Phases
|
||||||
|
|
||||||
### Phase 1: Basic Infrastructure (Week 1)
|
### Phase 1: Basic Infrastructure (Week 1) ✅ COMPLETED
|
||||||
- [ ] Create `/plugins/` directory structure
|
- [x] Create `/plugins/` directory structure
|
||||||
- [ ] Create Django `plugins` app with models
|
- [x] Create Django `plugins` app with models
|
||||||
- [ ] Migrate existing WP plugin to `/plugins/wordpress/source/`
|
- [x] Migrate existing WP plugin to `/plugins/wordpress/source/`
|
||||||
- [ ] Create build script for ZIP generation
|
- [x] Create build script for ZIP generation
|
||||||
- [ ] Implement download API endpoint
|
- [x] Implement download API endpoint
|
||||||
|
|
||||||
### Phase 2: Frontend Integration (Week 1)
|
### Phase 2: Frontend Integration (Week 1) ✅ COMPLETED
|
||||||
- [ ] Update Site Settings > Integrations download button
|
- [x] Update Site Settings > Integrations download button
|
||||||
- [ ] Create plugin info API endpoint
|
- [x] Create plugin info API endpoint
|
||||||
- [ ] Add version display to download section
|
- [x] Add version display to download section
|
||||||
|
|
||||||
### Phase 3: Update System (Week 2)
|
### Phase 3: Update System (Week 2) ✅ COMPLETED
|
||||||
- [ ] Implement check-update API
|
- [x] Implement check-update API
|
||||||
- [ ] Add update hooks to WP plugin
|
- [x] Add update hooks to WP plugin
|
||||||
- [ ] Create PluginInstallation tracking
|
- [x] Create PluginInstallation tracking
|
||||||
- [ ] Build admin version management UI
|
- [x] Build admin version management UI
|
||||||
|
|
||||||
### Phase 4: Advanced Features (Week 3)
|
### Phase 4: Advanced Features (Week 3)
|
||||||
- [ ] Signed download URLs
|
- [ ] Signed download URLs
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import {
|
|||||||
TrashBinIcon,
|
TrashBinIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
RefreshCwIcon
|
RefreshCwIcon,
|
||||||
|
InfoIcon
|
||||||
} from '../../icons';
|
} from '../../icons';
|
||||||
|
|
||||||
interface WordPressIntegrationFormProps {
|
interface WordPressIntegrationFormProps {
|
||||||
@@ -45,6 +46,8 @@ export default function WordPressIntegrationForm({
|
|||||||
const [generatingKey, setGeneratingKey] = useState(false);
|
const [generatingKey, setGeneratingKey] = useState(false);
|
||||||
const [apiKey, setApiKey] = useState<string>('');
|
const [apiKey, setApiKey] = useState<string>('');
|
||||||
const [apiKeyVisible, setApiKeyVisible] = useState(false);
|
const [apiKeyVisible, setApiKeyVisible] = useState(false);
|
||||||
|
const [pluginInfo, setPluginInfo] = useState<any>(null);
|
||||||
|
const [loadingPlugin, setLoadingPlugin] = useState(false);
|
||||||
|
|
||||||
// Load API key from integration on mount or when integration changes
|
// Load API key from integration on mount or when integration changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,6 +58,23 @@ export default function WordPressIntegrationForm({
|
|||||||
}
|
}
|
||||||
}, [integration]);
|
}, [integration]);
|
||||||
|
|
||||||
|
// Fetch plugin information
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPluginInfo = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingPlugin(true);
|
||||||
|
const response = await fetchAPI('/plugins/wordpress/latest/');
|
||||||
|
setPluginInfo(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch plugin info:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingPlugin(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPluginInfo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleGenerateApiKey = async () => {
|
const handleGenerateApiKey = async () => {
|
||||||
try {
|
try {
|
||||||
setGeneratingKey(true);
|
setGeneratingKey(true);
|
||||||
@@ -151,7 +171,8 @@ export default function WordPressIntegrationForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadPlugin = () => {
|
const handleDownloadPlugin = () => {
|
||||||
const pluginUrl = `https://github.com/igny8/igny8-wp-bridge/releases/latest/download/igny8-wp-bridge.zip`;
|
// Use the backend API endpoint for plugin download
|
||||||
|
const pluginUrl = `/api/plugins/igny8-wp-bridge/download/`;
|
||||||
window.open(pluginUrl, '_blank');
|
window.open(pluginUrl, '_blank');
|
||||||
toast.success('Plugin download started');
|
toast.success('Plugin download started');
|
||||||
};
|
};
|
||||||
@@ -371,23 +392,93 @@ export default function WordPressIntegrationForm({
|
|||||||
{/* Plugin Download Section */}
|
{/* Plugin Download Section */}
|
||||||
{apiKey && (
|
{apiKey && (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-4">
|
||||||
<div>
|
<div className="flex items-start justify-between">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
<div className="flex-1">
|
||||||
<DownloadIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
IGNY8 WP Bridge Plugin
|
<DownloadIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
</h3>
|
IGNY8 WP Bridge Plugin
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
</h3>
|
||||||
Download and install the plugin on your WordPress site
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
</p>
|
Download and install the plugin on your WordPress site
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleDownloadPlugin}
|
||||||
|
variant="solid"
|
||||||
|
startIcon={<DownloadIcon className="w-4 h-4" />}
|
||||||
|
disabled={loadingPlugin}
|
||||||
|
>
|
||||||
|
Download Plugin
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
onClick={handleDownloadPlugin}
|
{/* Plugin Details */}
|
||||||
variant="solid"
|
{pluginInfo && (
|
||||||
startIcon={<DownloadIcon className="w-4 h-4" />}
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
Download Plugin
|
<div>
|
||||||
</Button>
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Version</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{pluginInfo.version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">File Size</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{(pluginInfo.file_size / 1024).toFixed(1)} KB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">WordPress Version</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
5.0+
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">PHP Version</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
7.4+
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Requirements & Instructions */}
|
||||||
|
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<InfoIcon className="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs font-medium text-blue-900 dark:text-blue-300 mb-1">
|
||||||
|
Installation Steps
|
||||||
|
</p>
|
||||||
|
<ol className="text-xs text-blue-800 dark:text-blue-400 space-y-1 list-decimal list-inside">
|
||||||
|
<li>Download the plugin ZIP file</li>
|
||||||
|
<li>Go to your WordPress admin → Plugins → Add New</li>
|
||||||
|
<li>Click "Upload Plugin" and select the ZIP file</li>
|
||||||
|
<li>Activate the plugin after installation</li>
|
||||||
|
<li>Configure plugin with your API key from above</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Changelog */}
|
||||||
|
{pluginInfo.changelog && (
|
||||||
|
<div className="mt-3 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white mb-1">What's New:</p>
|
||||||
|
<p className="whitespace-pre-line">{pluginInfo.changelog}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingPlugin && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
|
||||||
|
Loading plugin information...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user