""" 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 from igny8_core.business.publishing.services.deployment_readiness_service import DeploymentReadinessService from igny8_core.business.site_building.models import SiteBlueprint class PublishingRecordViewSet(SiteSectorModelViewSet): """ ViewSet for PublishingRecord model. """ queryset = PublishingRecord.objects.select_related('content', 'site_blueprint') permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] throttle_scope = 'publisher' throttle_classes = [DebugScopedRateThrottle] def get_serializer_class(self): # Will be created in next step from rest_framework import serializers class PublishingRecordSerializer(serializers.ModelSerializer): class Meta: model = PublishingRecord fields = '__all__' return PublishingRecordSerializer class DeploymentRecordViewSet(SiteSectorModelViewSet): """ ViewSet for DeploymentRecord model. """ queryset = DeploymentRecord.objects.select_related('site_blueprint') permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] throttle_scope = 'publisher' throttle_classes = [DebugScopedRateThrottle] def get_serializer_class(self): # Will be created in next step from rest_framework import serializers class DeploymentRecordSerializer(serializers.ModelSerializer): class Meta: model = DeploymentRecord fields = '__all__' return DeploymentRecordSerializer class PublisherViewSet(viewsets.ViewSet): """ Publisher actions for publishing content and sites. """ permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] throttle_scope = 'publisher' throttle_classes = [DebugScopedRateThrottle] def __init__(self, **kwargs): super().__init__(**kwargs) self.publisher_service = PublisherService() self.readiness_service = DeploymentReadinessService() @action(detail=False, methods=['post'], url_path='publish') def publish(self, request): """ Publish content or site to destinations. Request body: { "content_id": 123, # Optional: content to publish "site_blueprint_id": 456, # Optional: site to publish "destinations": ["wordpress", "sites"] # Required: list of destinations } """ content_id = request.data.get('content_id') site_blueprint_id = request.data.get('site_blueprint_id') destinations = request.data.get('destinations', []) if not destinations: return error_response( 'destinations is required', status.HTTP_400_BAD_REQUEST, request ) account = request.account if site_blueprint_id: # Publish site try: blueprint = SiteBlueprint.objects.get(id=site_blueprint_id, account=account) except SiteBlueprint.DoesNotExist: return error_response( f'Site blueprint {site_blueprint_id} not found', status.HTTP_404_NOT_FOUND, request ) if 'sites' in destinations: result = self.publisher_service.publish_to_sites(blueprint) return success_response(result, request=request) else: return error_response( 'Site publishing only supports "sites" destination', status.HTTP_400_BAD_REQUEST, request ) elif content_id: # Publish content result = self.publisher_service.publish_content( content_id, destinations, account ) return success_response(result, request=request) else: return error_response( 'Either content_id or site_blueprint_id is required', status.HTTP_400_BAD_REQUEST, request ) @action(detail=False, methods=['get'], url_path='blueprints/(?P[^/.]+)/readiness') def deployment_readiness(self, request, blueprint_id): """ Check deployment readiness for a site blueprint. Stage 4: Pre-deployment validation checks. GET /api/v1/publisher/blueprints/{blueprint_id}/readiness/ """ account = request.account try: blueprint = SiteBlueprint.objects.get(id=blueprint_id, account=account) except SiteBlueprint.DoesNotExist: return error_response( f'Site blueprint {blueprint_id} not found', status.HTTP_404_NOT_FOUND, request ) readiness = self.readiness_service.check_readiness(blueprint_id) return success_response(readiness, request=request) @action(detail=False, methods=['post'], url_path='deploy/(?P[^/.]+)') def deploy(self, request, blueprint_id): """ Deploy site blueprint to Sites renderer. Stage 4: Enhanced with readiness check (optional). POST /api/v1/publisher/deploy/{blueprint_id}/ Request body (optional): { "skip_readiness_check": false # Set to true to skip readiness validation } """ account = request.account try: blueprint = SiteBlueprint.objects.get(id=blueprint_id, account=account) except SiteBlueprint.DoesNotExist: return error_response( f'Site blueprint {blueprint_id} not found', status.HTTP_404_NOT_FOUND, request ) # Stage 4: Optional readiness check skip_check = request.data.get('skip_readiness_check', False) if not skip_check: readiness = self.readiness_service.check_readiness(blueprint_id) if not readiness.get('ready'): return error_response( { 'message': 'Site is not ready for deployment', 'readiness': readiness }, status.HTTP_400_BAD_REQUEST, request ) result = self.publisher_service.publish_to_sites(blueprint) response_status = status.HTTP_202_ACCEPTED if result.get('success') else status.HTTP_400_BAD_REQUEST return success_response(result, request=request, status_code=response_status) @action(detail=False, methods=['get'], url_path='status/(?P[^/.]+)') def get_status(self, request, id): """ Get publishing/deployment status. GET /api/v1/publisher/status/{id}/ """ account = request.account # Try deployment record first try: deployment = DeploymentRecord.objects.get(id=id, account=account) return success_response({ 'type': 'deployment', 'status': deployment.status, 'version': deployment.version, 'deployed_version': deployment.deployed_version, 'deployment_url': deployment.deployment_url, 'deployed_at': deployment.deployed_at, 'error_message': deployment.error_message, }, request=request) except DeploymentRecord.DoesNotExist: pass # Try 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 )