""" Publisher ViewSet Phase 5: Sites Renderer & Publishing """ import json import os from pathlib import Path from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.views import APIView from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator from igny8_core.api.base import SiteSectorModelViewSet from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove from igny8_core.api.response import success_response, error_response from igny8_core.api.throttles import DebugScopedRateThrottle from igny8_core.business.publishing.models import PublishingRecord, DeploymentRecord from igny8_core.business.publishing.services.publisher_service import PublisherService class PublishingRecordViewSet(SiteSectorModelViewSet): """ ViewSet for PublishingRecord model. """ queryset = PublishingRecord.objects.select_related('content') permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] throttle_scope = 'publisher' throttle_classes = [DebugScopedRateThrottle] def get_serializer_class(self): # Dynamically create serializer from rest_framework import serializers class PublishingRecordSerializer(serializers.ModelSerializer): class Meta: model = PublishingRecord exclude = [] # Legacy: site_blueprint field removed from model return PublishingRecordSerializer class DeploymentRecordViewSet(SiteSectorModelViewSet): """ ViewSet for DeploymentRecord model. Legacy: SiteBlueprint functionality removed. """ queryset = DeploymentRecord.objects.all() permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] throttle_scope = 'publisher' throttle_classes = [DebugScopedRateThrottle] def get_serializer_class(self): # Dynamically create serializer from rest_framework import serializers class DeploymentRecordSerializer(serializers.ModelSerializer): class Meta: model = DeploymentRecord exclude = [] # Legacy: site_blueprint field removed from model return DeploymentRecordSerializer class PublisherViewSet(viewsets.ViewSet): """ Publisher actions for publishing content. Legacy SiteBlueprint publishing removed - only content publishing supported. """ permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] throttle_scope = 'publisher' throttle_classes = [DebugScopedRateThrottle] def __init__(self, **kwargs): super().__init__(**kwargs) self.publisher_service = PublisherService() @action(detail=False, methods=['post'], url_path='publish') def publish(self, request): """ Publish content to destinations. Request body: { "content_id": 123, # Required: content to publish "destinations": ["wordpress"] # Required: list of destinations } """ import logging logger = logging.getLogger(__name__) content_id = request.data.get('content_id') destinations = request.data.get('destinations', []) logger.info(f"[PublisherViewSet.publish] 🚀 Publish request received: content_id={content_id}, destinations={destinations}") if not content_id: return error_response( 'content_id is required', status.HTTP_400_BAD_REQUEST, request ) if not destinations: return error_response( 'destinations is required', status.HTTP_400_BAD_REQUEST, request ) account = request.account # Publish content logger.info(f"[PublisherViewSet.publish] 📝 Publishing content {content_id} to {destinations}") result = self.publisher_service.publish_content( content_id, destinations, account ) logger.info(f"[PublisherViewSet.publish] {'✅' if result.get('success') else '❌'} Publish result: {result}") return success_response(result, request=request) @action(detail=False, methods=['get'], url_path='status/(?P[^/.]+)') def get_status(self, request, id): """ Get publishing status. GET /api/v1/publisher/status/{id}/ """ account = request.account # Get publishing record try: publishing = PublishingRecord.objects.get(id=id, account=account) return success_response({ 'type': 'publishing', 'status': publishing.status, 'destination': publishing.destination, 'destination_url': publishing.destination_url, 'published_at': publishing.published_at, 'error_message': publishing.error_message, }, request=request) except PublishingRecord.DoesNotExist: return error_response( f'Record {id} not found', status.HTTP_404_NOT_FOUND, request ) @method_decorator(csrf_exempt, name='dispatch') class SiteDefinitionView(APIView): """ Public endpoint to serve deployed site definitions. Used by Sites Renderer to load site definitions. No authentication required for public sites. """ permission_classes = [] # Public endpoint authentication_classes = [] # No authentication required def get(self, request, site_id): """ Get deployed site definition. GET /api/v1/publisher/sites/{site_id}/definition/ """ try: sites_data_path = os.getenv('SITES_DATA_PATH', '/data/app/sites-data') # Try to find latest deployed version site_dir = Path(sites_data_path) / 'clients' / str(site_id) if not site_dir.exists(): return error_response( error=f'Site {site_id} not found at {site_dir}. Site may not be deployed yet.', status_code=status.HTTP_404_NOT_FOUND, request=request ) # Look for latest version (check for 'latest' symlink or highest version number) latest_path = site_dir / 'latest' / 'site.json' if latest_path.exists(): try: with open(latest_path, 'r', encoding='utf-8') as f: definition = json.load(f) return Response(definition, status=status.HTTP_200_OK) except json.JSONDecodeError as e: return error_response( error=f'Invalid JSON in site definition file: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) except Exception as e: return error_response( error=f'Failed to load site definition from {latest_path}: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) # Fallback: find highest version number try: version_dirs = [d for d in site_dir.iterdir() if d.is_dir() and d.name.startswith('v')] except PermissionError as e: return error_response( error=f'Permission denied accessing site directory: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) except Exception as e: return error_response( error=f'Error reading site directory: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) if not version_dirs: return error_response( error=f'No deployed versions found for site {site_id}. Site may not be deployed yet.', status_code=status.HTTP_404_NOT_FOUND, request=request ) # Sort by version number (extract number from 'v1', 'v2', etc.) try: version_dirs.sort(key=lambda d: int(d.name[1:]) if d.name[1:].isdigit() else 0, reverse=True) latest_version_dir = version_dirs[0] site_json_path = latest_version_dir / 'site.json' if site_json_path.exists(): with open(site_json_path, 'r', encoding='utf-8') as f: definition = json.load(f) return Response(definition, status=status.HTTP_200_OK) else: return error_response( error=f'Site definition file not found at {site_json_path}', status_code=status.HTTP_404_NOT_FOUND, request=request ) except json.JSONDecodeError as e: return error_response( error=f'Invalid JSON in site definition file: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) except Exception as e: return error_response( error=f'Failed to load site definition: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) return error_response( error=f'Site definition not found for site {site_id}', status_code=status.HTTP_404_NOT_FOUND, request=request ) except Exception as e: # Catch any unhandled exceptions import logging logger = logging.getLogger(__name__) logger.error(f'Unhandled exception in SiteDefinitionView: {str(e)}', exc_info=True) return error_response( error=f'Internal server error: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request )