Files
igny8/backend/igny8_core/modules/publisher/views.py
2025-11-20 04:00:51 +05:00

368 lines
14 KiB
Python

"""
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<blueprint_id>[^/.]+)/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<blueprint_id>[^/.]+)')
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<id>[^/.]+)')
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
)