Files
2026-01-09 21:38:14 +00:00

659 lines
22 KiB
Python

"""
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