""" Integration ViewSet Phase 6: Site Integration & Multi-Destination Publishing """ from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response from django.utils import timezone from drf_spectacular.utils import extend_schema, extend_schema_view 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.integration.models import SiteIntegration from igny8_core.business.integration.services.integration_service import IntegrationService from igny8_core.business.integration.services.sync_service import SyncService from igny8_core.business.integration.services.sync_health_service import SyncHealthService from igny8_core.business.integration.services.content_sync_service import ContentSyncService import logging logger = logging.getLogger(__name__) @extend_schema_view( list=extend_schema(tags=['Integration']), create=extend_schema(tags=['Integration']), retrieve=extend_schema(tags=['Integration']), update=extend_schema(tags=['Integration']), partial_update=extend_schema(tags=['Integration']), destroy=extend_schema(tags=['Integration']), ) class IntegrationViewSet(SiteSectorModelViewSet): """ ViewSet for SiteIntegration model. """ queryset = SiteIntegration.objects.select_related('site') permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] throttle_scope = 'integration' throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """ Override to filter integrations by site. SiteIntegration only has 'site' field (no 'sector'), so SiteSectorModelViewSet's filtering doesn't apply. We manually filter by site here. """ queryset = super().get_queryset() # Get site parameter from query params site_id = self.request.query_params.get('site_id') or self.request.query_params.get('site') if site_id: try: site_id_int = int(site_id) queryset = queryset.filter(site_id=site_id_int) except (ValueError, TypeError): # Invalid site_id, return empty queryset queryset = queryset.none() return queryset def get_serializer_class(self): from rest_framework import serializers class SiteIntegrationSerializer(serializers.ModelSerializer): api_key = serializers.SerializerMethodField() def get_api_key(self, obj): """Return the API key from Site.wp_api_key (SINGLE source of truth)""" # API key is stored on Site model, not in SiteIntegration credentials return obj.site.wp_api_key or '' def validate(self, data): """ Custom validation for WordPress integrations. API key is stored on Site model, not in SiteIntegration. """ validated_data = super().validate(data) # For WordPress platform, check API key exists on Site (not in credentials_json) if validated_data.get('platform') == 'wordpress': site = validated_data.get('site') or getattr(self.instance, 'site', None) if site and not site.wp_api_key: raise serializers.ValidationError({ 'site': 'Site must have an API key generated before creating WordPress integration.' }) return validated_data class Meta: model = SiteIntegration fields = '__all__' read_only_fields = ['created_at', 'updated_at', 'last_sync_at'] return SiteIntegrationSerializer @extend_schema(tags=['Integration']) @action(detail=True, methods=['post']) def test_connection(self, request, pk=None): """ Test connection to integrated platform. POST /api/v1/integration/integrations/{id}/test_connection/ """ integration = self.get_object() service = IntegrationService() result = service.test_connection(integration) if result.get('success'): return success_response(result, request=request) else: return error_response( result.get('message', 'Connection test failed'), None, status.HTTP_400_BAD_REQUEST, request ) from rest_framework.permissions import AllowAny from rest_framework.throttling import BaseThrottle class NoThrottle(BaseThrottle): """Temporary throttle class that allows all requests""" def allow_request(self, request, view): return True @extend_schema(tags=['Integration']) @action(detail=False, methods=['post'], url_path='test-connection', permission_classes=[AllowAny], throttle_classes=[NoThrottle]) def test_connection_collection(self, request): """ Collection-level test connection endpoint for frontend convenience. POST /api/v1/integration/integrations/test-connection/ Body: { "site_id": 123, "api_key": "...", "site_url": "https://example.com" } """ site_id = request.data.get('site_id') api_key = request.data.get('api_key') site_url = request.data.get('site_url') if not site_id: return error_response('site_id is required', None, status.HTTP_400_BAD_REQUEST, request) # Verify site exists from igny8_core.auth.models import Site try: site = Site.objects.get(id=int(site_id)) except (Site.DoesNotExist, ValueError, TypeError): return error_response('Site not found or invalid', None, status.HTTP_404_NOT_FOUND, request) # Authentication: accept either authenticated user OR matching API key in body api_key = request.data.get('api_key') or api_key authenticated = False # If request has a valid user and belongs to same account, allow if hasattr(request, 'user') and getattr(request.user, 'is_authenticated', False): try: # If user has account, ensure site belongs to user's account if site.account == request.user.account: authenticated = True except Exception: # Ignore and fallback to api_key check pass # If not authenticated via session, allow if provided api_key matches site's stored wp_api_key if not authenticated: stored_key = getattr(site, 'wp_api_key', None) if stored_key and api_key and str(api_key) == str(stored_key): authenticated = True elif not stored_key: # API key not set on site - provide helpful error message return error_response( 'API key not configured for this site. Please generate an API key in the IGNY8 app and ensure it is saved to the site.', None, status.HTTP_403_FORBIDDEN, request ) elif api_key and stored_key and str(api_key) != str(stored_key): # API key provided but doesn't match return error_response( 'Invalid API key. The provided API key does not match the one stored for this site.', None, status.HTTP_403_FORBIDDEN, request ) if not authenticated: return error_response('Authentication credentials were not provided.', None, status.HTTP_403_FORBIDDEN, request) # Try to find an existing integration for this site+platform integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first() # If not found, create and save the integration to database (for status tracking, not credentials) integration_created = False if not integration: integration = SiteIntegration.objects.create( account=site.account, site=site, platform='wordpress', platform_type='cms', config_json={'site_url': site_url} if site_url else {}, credentials_json={}, # API key is stored in Site.wp_api_key, not here is_active=True, sync_enabled=True ) integration_created = True logger.info(f"[IntegrationViewSet] Created WordPress integration {integration.id} for site {site.id}") service = IntegrationService() # Mark this as initial connection test since API key was provided in request body # This allows the test to pass even if WordPress plugin hasn't stored the key yet is_initial_connection = bool(api_key and request.data.get('api_key')) result = service._test_wordpress_connection(integration, is_initial_connection=is_initial_connection) if result.get('success'): # Include integration_id in response so plugin can store it result['integration_id'] = integration.id result['integration_created'] = integration_created return success_response(result, request=request) else: # If test failed and we just created integration, delete it if integration_created: integration.delete() logger.info(f"[IntegrationViewSet] Deleted integration {integration.id} due to failed connection test") return error_response(result.get('message', 'Connection test failed'), None, status.HTTP_400_BAD_REQUEST, request) @extend_schema(tags=['Integration']) @action(detail=True, methods=['post']) def sync(self, request, pk=None): """ Trigger synchronization with integrated platform. POST /api/v1/integration/integrations/{id}/sync/ Request body: { "direction": "metadata", # 'metadata', 'both', 'to_external', 'from_external' "content_types": ["blog_post", "page"] # Optional } """ integration = self.get_object() direction = request.data.get('direction', 'metadata') content_types = request.data.get('content_types') # Handle metadata-only sync (for "Sync Now" button) if direction == 'metadata': from igny8_core.business.integration.services.sync_metadata_service import SyncMetadataService metadata_service = SyncMetadataService() result = metadata_service.sync_wordpress_structure(integration) else: # Full content sync (legacy behavior) sync_service = SyncService() result = sync_service.sync(integration, direction=direction, content_types=content_types) response_status = status.HTTP_200_OK if result.get('success') else status.HTTP_400_BAD_REQUEST return success_response(result, request=request, status_code=response_status) @extend_schema(tags=['Integration']) @action(detail=True, methods=['get']) def sync_status(self, request, pk=None): """ Get sync status for integration. GET /api/v1/integration/integrations/{id}/sync_status/ """ integration = self.get_object() sync_service = SyncService() status_data = sync_service.get_sync_status(integration) return success_response(status_data, request=request) @action(detail=True, methods=['post'], url_path='update-structure') def update_site_structure(self, request, pk=None): """ Update WordPress site structure (post types, taxonomies, counts). Called by WordPress plugin to push site configuration to backend. POST /api/v1/integration/integrations/{id}/update-structure/ Request body: { "post_types": { "post": {"label": "Posts", "count": 123, "enabled": true, "fetch_limit": 100}, "page": {"label": "Pages", "count": 12, "enabled": true, "fetch_limit": 100}, "product": {"label": "Products", "count": 456, "enabled": false, "fetch_limit": 50} }, "taxonomies": { "category": {"label": "Categories", "count": 25, "enabled": true, "fetch_limit": 100}, "post_tag": {"label": "Tags", "count": 102, "enabled": true, "fetch_limit": 100}, "product_cat": {"label": "Product Categories", "count": 15, "enabled": false, "fetch_limit": 50} }, "plugin_connection_enabled": true, "two_way_sync_enabled": true } """ integration = self.get_object() # Update config with new structure config = integration.config_json or {} post_types = request.data.get('post_types', {}) taxonomies = request.data.get('taxonomies', {}) if post_types or taxonomies: config['content_types'] = { 'post_types': post_types, 'taxonomies': taxonomies, 'last_structure_fetch': request.data.get('timestamp') or str(timezone.now().isoformat()) } config['plugin_connection_enabled'] = request.data.get('plugin_connection_enabled', True) config['two_way_sync_enabled'] = request.data.get('two_way_sync_enabled', True) integration.config_json = config integration.save() return success_response({ 'message': 'Site structure updated successfully', 'post_types_count': len(post_types), 'taxonomies_count': len(taxonomies), 'last_structure_fetch': config['content_types']['last_structure_fetch'] }, request=request) return error_response( 'No post types or taxonomies provided', None, status.HTTP_400_BAD_REQUEST, request ) @action(detail=True, methods=['get'], url_path='content-types') def content_types_summary(self, request, pk=None): """ Get content types summary with counts from synced data. GET /api/v1/integration/integrations/{id}/content-types/ Returns: { "success": true, "data": { "post_types": { "post": {"label": "Posts", "count": 123, "synced_count": 50}, "page": {"label": "Pages", "count": 12, "synced_count": 12}, "product": {"label": "Products", "count": 456, "synced_count": 200} }, "taxonomies": { "category": {"label": "Categories", "count": 25, "synced_count": 25}, "post_tag": {"label": "Tags", "count": 102, "synced_count": 80}, "product_cat": {"label": "Product Categories", "count": 15, "synced_count": 15} }, "last_structure_fetch": "2025-11-22T10:00:00Z" } } """ integration = self.get_object() site = integration.site # Get config from integration config = integration.config_json or {} content_types = config.get('content_types', {}) # Get synced counts from Content and ContentTaxonomy models from igny8_core.business.content.models import Content, ContentTaxonomy # Build response with synced counts post_types_data = {} for wp_type, type_config in content_types.get('post_types', {}).items(): # Map WP type to content_type content_type_map = { 'post': 'post', 'page': 'page', 'product': 'product', 'service': 'service', } content_type = content_type_map.get(wp_type, 'post') # Count synced content synced_count = Content.objects.filter( site=site, content_type=content_type, external_type=wp_type, sync_status__in=['imported', 'synced'] ).count() post_types_data[wp_type] = { 'label': type_config.get('label', wp_type.title()), 'count': type_config.get('count', 0), 'synced_count': synced_count, 'enabled': type_config.get('enabled', False), 'fetch_limit': type_config.get('fetch_limit', 100), 'last_synced': type_config.get('last_synced'), } taxonomies_data = {} for wp_tax, tax_config in content_types.get('taxonomies', {}).items(): # Count synced taxonomies synced_count = ContentTaxonomy.objects.filter( site=site, external_taxonomy=wp_tax ).count() taxonomies_data[wp_tax] = { 'label': tax_config.get('label', wp_tax.title()), 'count': tax_config.get('count', 0), 'synced_count': synced_count, 'enabled': tax_config.get('enabled', False), 'fetch_limit': tax_config.get('fetch_limit', 100), 'last_synced': tax_config.get('last_synced'), } summary = { 'post_types': post_types_data, 'taxonomies': taxonomies_data, 'last_structure_fetch': content_types.get('last_structure_fetch'), 'plugin_connection_enabled': config.get('plugin_connection_enabled', True), 'two_way_sync_enabled': config.get('two_way_sync_enabled', True), } return success_response(summary, request=request) # Stage 4: Site-level sync endpoints @action(detail=False, methods=['get'], url_path='sites/(?P[^/.]+)/sync/status') def sync_status_by_site(self, request, site_id=None): """ Get sync status for all integrations on a site. Stage 4: Site-level sync health endpoint. GET /api/v1/integration/integrations/sites/{site_id}/sync/status/ """ try: site_id_int = int(site_id) except (ValueError, TypeError): return error_response( 'Invalid site_id', None, status.HTTP_400_BAD_REQUEST, request ) # Verify site belongs to user's account from igny8_core.auth.models import Site try: site = Site.objects.get(id=site_id_int, account=request.user.account) except Site.DoesNotExist: return error_response( 'Site not found', None, status.HTTP_404_NOT_FOUND, request ) sync_health_service = SyncHealthService() status_data = sync_health_service.get_sync_status(site_id_int) return success_response(status_data, request=request) @action(detail=False, methods=['post'], url_path='sites/(?P[^/.]+)/sync/run') def run_sync(self, request, site_id=None): """ Trigger sync for all integrations on a site. Stage 4: Site-level sync trigger endpoint. POST /api/v1/integration/integrations/sites/{site_id}/sync/run/ Request body: { "direction": "both", # Optional: 'both', 'to_external', 'from_external' "content_types": ["blog_post", "product"] # Optional } """ try: site_id_int = int(site_id) except (ValueError, TypeError): return error_response( 'Invalid site_id', None, status.HTTP_400_BAD_REQUEST, request ) # Verify site belongs to user's account from igny8_core.auth.models import Site try: site = Site.objects.get(id=site_id_int, account=request.user.account) except Site.DoesNotExist: return error_response( 'Site not found', None, status.HTTP_404_NOT_FOUND, request ) direction = request.data.get('direction', 'both') content_types = request.data.get('content_types') # Get all active integrations for this site integrations = SiteIntegration.objects.filter( site_id=site_id_int, is_active=True, sync_enabled=True ) if not integrations.exists(): return error_response( 'No active integrations found for this site', None, status.HTTP_400_BAD_REQUEST, request ) sync_service = SyncService() sync_health_service = SyncHealthService() results = [] for integration in integrations: result = sync_service.sync(integration, direction=direction, content_types=content_types) # Record sync run sync_health_service.record_sync_run(integration.id, result) results.append({ 'integration_id': integration.id, 'platform': integration.platform, 'result': result }) return success_response({ 'site_id': site_id_int, 'sync_results': results, 'total_integrations': len(results) }, request=request) @action(detail=False, methods=['get'], url_path='sites/(?P[^/.]+)/sync/mismatches') def get_mismatches(self, request, site_id=None): """ Get sync mismatches for a site. Stage 4: Detailed mismatch information. GET /api/v1/integration/integrations/sites/{site_id}/sync/mismatches/ """ try: site_id_int = int(site_id) except (ValueError, TypeError): return error_response( 'Invalid site_id', None, status.HTTP_400_BAD_REQUEST, request ) # Verify site belongs to user's account from igny8_core.auth.models import Site try: site = Site.objects.get(id=site_id_int, account=request.user.account) except Site.DoesNotExist: return error_response( 'Site not found', None, status.HTTP_404_NOT_FOUND, request ) sync_health_service = SyncHealthService() mismatches = sync_health_service.get_mismatches(site_id_int) return success_response(mismatches, request=request) @action(detail=False, methods=['get'], url_path='sites/(?P[^/.]+)/sync/logs') def get_sync_logs(self, request, site_id=None): """ Get sync logs for a site. Stage 4: Sync history and logs. GET /api/v1/integration/integrations/sites/{site_id}/sync/logs/ Query params: - limit: Number of logs to return (default: 100) - integration_id: Filter by specific integration """ try: site_id_int = int(site_id) except (ValueError, TypeError): return error_response( 'Invalid site_id', None, status.HTTP_400_BAD_REQUEST, request ) # Verify site belongs to user's account from igny8_core.auth.models import Site try: site = Site.objects.get(id=site_id_int, account=request.user.account) except Site.DoesNotExist: return error_response( 'Site not found', None, status.HTTP_404_NOT_FOUND, request ) limit = int(request.query_params.get('limit', 100)) integration_id = request.query_params.get('integration_id') sync_health_service = SyncHealthService() logs = sync_health_service.get_sync_logs( site_id_int, integration_id=int(integration_id) if integration_id else None, limit=limit ) return success_response({ 'site_id': site_id_int, 'logs': logs, 'count': len(logs) }, request=request) @action(detail=True, methods=['get'], url_path='debug-status') def debug_status(self, request, pk=None): """ Get comprehensive debug status for WordPress integration. Includes health, recent events, and data validation. GET /api/v1/integration/integrations/{id}/debug-status/ Query params: - include_events: Include recent sync events (default: true) - include_validation: Include data validation matrix (default: false) - event_limit: Number of events to return (default: 50) """ from igny8_core.business.integration.models import SyncEvent integration = self.get_object() include_events = request.query_params.get('include_events', 'true').lower() == 'true' include_validation = request.query_params.get('include_validation', 'false').lower() == 'true' event_limit = int(request.query_params.get('event_limit', 50)) # Get integration health health_data = { 'api_status': 'healthy' if integration.is_active else 'error', 'api_message': 'Integration is active' if integration.is_active else 'Integration is inactive', 'last_api_check': integration.updated_at.isoformat() if integration.updated_at else None, 'plugin_active': integration.is_active, 'plugin_version': integration.credentials_json.get('plugin_version', 'Unknown') if integration.credentials_json else 'Unknown', 'debug_mode': integration.credentials_json.get('debug_enabled', False) if integration.credentials_json else False, 'sync_healthy': integration.last_sync_at is not None, 'pending_syncs': 0, # TODO: Calculate from actual sync queue 'last_sync': integration.last_sync_at.isoformat() if integration.last_sync_at else None, } response_data = { 'health': health_data, } # Include sync events if requested if include_events: # Get real-time sync events from database events_qs = SyncEvent.objects.filter( integration=integration ).order_by('-created_at')[:event_limit] # Format events for frontend events = [] for event in events_qs: events.append({ 'id': event.id, 'type': event.event_type, 'action': event.action, 'description': event.description, 'timestamp': event.created_at.isoformat(), 'success': event.success, 'content_id': event.content_id, 'external_id': event.external_id, 'error_message': event.error_message, 'duration_ms': event.duration_ms, 'details': event.details, }) response_data['events'] = events response_data['events_count'] = len(events) # Include data validation if requested if include_validation: # TODO: Implement actual data validation check # For now, return placeholder data response_data['validation'] = [ { 'field_name': 'content_title', 'igny8_value': 'Sample Title', 'wp_value': 'Sample Title', 'matches': True, }, { 'field_name': 'content_html', 'igny8_value': '

Content

', 'wp_value': '

Content

', 'matches': True, }, ] return success_response(response_data, request=request) @action(detail=True, methods=['post'], url_path='trigger-debug') def trigger_debug(self, request, pk=None): """ Enable/disable debug mode for WordPress integration. POST /api/v1/integration/integrations/{id}/trigger-debug/ Body: { "debug_enabled": true } """ integration = self.get_object() debug_enabled = request.data.get('debug_enabled', False) # Update credentials with debug flag credentials = integration.credentials_json or {} credentials['debug_enabled'] = debug_enabled integration.credentials_json = credentials integration.save() logger.info( f"WordPress debug mode {'enabled' if debug_enabled else 'disabled'} " f"for integration {integration.id} (site: {integration.site.name})" ) return success_response({ 'debug_enabled': debug_enabled, 'message': f"Debug mode {'enabled' if debug_enabled else 'disabled'} successfully", }, request=request) @action(detail=True, methods=['post'], url_path='test-connection') def test_connection_detail(self, request, pk=None): """ Test connection to WordPress site. POST /api/v1/integration/integrations/{id}/test-connection/ """ integration = self.get_object() service = IntegrationService() result = service.test_connection(integration) if result.get('success'): return success_response(result, request=request) else: return error_response( result.get('message', 'Connection test failed'), result.get('details'), status.HTTP_400_BAD_REQUEST, request ) @action(detail=False, methods=['post'], url_path='generate-api-key') def generate_api_key(self, request): """ Generate a new API key for a site's WordPress integration. POST /api/v1/integration/integrations/generate-api-key/ Body: { "site_id": 5 } """ site_id = request.data.get('site_id') if not site_id: return error_response( 'Site ID is required', None, status.HTTP_400_BAD_REQUEST, request ) try: site = Site.objects.get(id=site_id) except Site.DoesNotExist: return error_response( f'Site with ID {site_id} not found', None, status.HTTP_404_NOT_FOUND, request ) # Generate API key with format: igny8_site_{site_id}_{timestamp}_{random} import time import random import string timestamp = int(time.time() * 1000) random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10)) api_key = f"igny8_site_{site_id}_{timestamp}_{random_suffix}" # SINGLE SOURCE OF TRUTH: Store API key ONLY in Site.wp_api_key # This is where APIKeyAuthentication validates against site.wp_api_key = api_key site.save(update_fields=['wp_api_key']) # Get or create SiteIntegration (for integration status/config, NOT credentials) integration, created = SiteIntegration.objects.get_or_create( site=site, platform='wordpress', defaults={ 'account': site.account, 'platform': 'wordpress', 'platform_type': 'cms', 'is_active': True, 'sync_enabled': True, 'credentials_json': {}, # Empty - API key is on Site model 'config_json': {} } ) # If integration already exists, just ensure it's active if not created: integration.is_active = True integration.sync_enabled = True # Clear any old credentials_json API key (migrate to Site.wp_api_key) if integration.credentials_json.get('api_key'): integration.credentials_json = {} integration.save() logger.info( f"Generated new API key for site {site.name} (ID: {site_id}), " f"stored in Site.wp_api_key (single source of truth)" ) # Serialize the integration with the new key serializer = self.get_serializer(integration) return success_response({ 'integration': serializer.data, 'api_key': api_key, 'message': f"API key {'generated' if created else 'regenerated'} successfully", }, request=request) # PublishingSettings ViewSet from rest_framework import serializers, viewsets from igny8_core.business.integration.models import PublishingSettings class PublishingSettingsSerializer(serializers.ModelSerializer): """Serializer for PublishingSettings model""" class Meta: model = PublishingSettings fields = [ 'id', 'site', 'auto_approval_enabled', 'auto_publish_enabled', 'daily_publish_limit', 'weekly_publish_limit', 'monthly_publish_limit', 'publish_days', 'publish_time_slots', 'created_at', 'updated_at', ] read_only_fields = ['id', 'site', 'created_at', 'updated_at'] @extend_schema_view( retrieve=extend_schema(tags=['Integration']), update=extend_schema(tags=['Integration']), partial_update=extend_schema(tags=['Integration']), ) class PublishingSettingsViewSet(viewsets.ViewSet): """ ViewSet for managing site-level publishing settings. GET /api/v1/integration/sites/{site_id}/publishing-settings/ PUT /api/v1/integration/sites/{site_id}/publishing-settings/ PATCH /api/v1/integration/sites/{site_id}/publishing-settings/ """ permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] throttle_scope = 'integration' throttle_classes = [DebugScopedRateThrottle] def _get_site(self, site_id, request): """Get site and verify user has access""" from igny8_core.auth.models import Site try: site = Site.objects.get(id=int(site_id)) # Check if user has access to this site (same account) if hasattr(request, 'account') and site.account != request.account: return None return site except (Site.DoesNotExist, ValueError, TypeError): return None @extend_schema(tags=['Integration']) def retrieve(self, request, site_id=None): """ Get publishing settings for a site. Creates default settings if they don't exist. """ site = self._get_site(site_id, request) if not site: return error_response( 'Site not found or access denied', None, status.HTTP_404_NOT_FOUND, request ) # Get or create settings with defaults settings, created = PublishingSettings.get_or_create_for_site(site) serializer = PublishingSettingsSerializer(settings) return success_response( data=serializer.data, message='Publishing settings retrieved' + (' (created with defaults)' if created else ''), request=request ) @extend_schema(tags=['Integration']) def update(self, request, site_id=None): """ Update publishing settings for a site (full update). """ site = self._get_site(site_id, request) if not site: return error_response( 'Site not found or access denied', None, status.HTTP_404_NOT_FOUND, request ) # Get or create settings settings, _ = PublishingSettings.get_or_create_for_site(site) serializer = PublishingSettingsSerializer(settings, data=request.data) if serializer.is_valid(): serializer.save() return success_response( data=serializer.data, message='Publishing settings updated', request=request ) return error_response( 'Validation failed', serializer.errors, status.HTTP_400_BAD_REQUEST, request ) @extend_schema(tags=['Integration']) def partial_update(self, request, site_id=None): """ Partially update publishing settings for a site. """ site = self._get_site(site_id, request) if not site: return error_response( 'Site not found or access denied', None, status.HTTP_404_NOT_FOUND, request ) # Get or create settings settings, _ = PublishingSettings.get_or_create_for_site(site) serializer = PublishingSettingsSerializer(settings, data=request.data, partial=True) if serializer.is_valid(): serializer.save() return success_response( data=serializer.data, message='Publishing settings updated', request=request ) return error_response( 'Validation failed', serializer.errors, status.HTTP_400_BAD_REQUEST, request )