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