diff --git a/backend/igny8_core/api/wordpress_publishing.py b/backend/igny8_core/api/wordpress_publishing.py new file mode 100644 index 00000000..f2dd09c1 --- /dev/null +++ b/backend/igny8_core/api/wordpress_publishing.py @@ -0,0 +1,400 @@ +""" +WordPress Publishing API Views +Handles manual content publishing to WordPress sites +""" +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from typing import Dict, Any, List + +from igny8_core.models import ContentPost, SiteIntegration +from igny8_core.tasks.wordpress_publishing import ( + publish_content_to_wordpress, + bulk_publish_content_to_wordpress +) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def publish_single_content(request, content_id: int) -> Response: + """ + Publish a single content item to WordPress + + POST /api/v1/content/{content_id}/publish-to-wordpress/ + + Body: + { + "site_integration_id": 123, // Optional - will use default if not provided + "force": false // Optional - force republish even if already published + } + """ + try: + content = get_object_or_404(ContentPost, id=content_id) + + # Check permissions + if not request.user.has_perm('content.change_contentpost'): + return Response( + { + 'success': False, + 'message': 'Permission denied', + 'error': 'insufficient_permissions' + }, + status=status.HTTP_403_FORBIDDEN + ) + + # Get site integration + site_integration_id = request.data.get('site_integration_id') + force = request.data.get('force', False) + + if site_integration_id: + site_integration = get_object_or_404(SiteIntegration, id=site_integration_id) + else: + # Get default WordPress integration for user's organization + site_integration = SiteIntegration.objects.filter( + platform='wordpress', + is_active=True, + # Add organization filter if applicable + ).first() + + if not site_integration: + return Response( + { + 'success': False, + 'message': 'No WordPress integration found', + 'error': 'no_integration' + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check if already published (unless force is true) + if not force and content.wordpress_sync_status == 'success': + return Response( + { + 'success': True, + 'message': 'Content already published to WordPress', + 'data': { + 'content_id': content.id, + 'wordpress_post_id': content.wordpress_post_id, + 'wordpress_post_url': content.wordpress_post_url, + 'status': 'already_published' + } + } + ) + + # Check if currently syncing + if content.wordpress_sync_status == 'syncing': + return Response( + { + 'success': False, + 'message': 'Content is currently being published to WordPress', + 'error': 'sync_in_progress' + }, + status=status.HTTP_409_CONFLICT + ) + + # Validate content is ready for publishing + if not content.title or not (content.content_html or content.content): + return Response( + { + 'success': False, + 'message': 'Content is incomplete - missing title or content', + 'error': 'incomplete_content' + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Set status to pending and queue the task + content.wordpress_sync_status = 'pending' + content.save(update_fields=['wordpress_sync_status']) + + # Get task_id if content is associated with a writer task + task_id = None + if hasattr(content, 'writer_task'): + task_id = content.writer_task.id + + # Queue the publishing task + task_result = publish_content_to_wordpress.delay( + content.id, + site_integration.id, + task_id + ) + + return Response( + { + 'success': True, + 'message': 'Content queued for WordPress publishing', + 'data': { + 'content_id': content.id, + 'site_integration_id': site_integration.id, + 'task_id': task_result.id, + 'status': 'queued' + } + }, + status=status.HTTP_202_ACCEPTED + ) + + except Exception as e: + return Response( + { + 'success': False, + 'message': f'Error queuing content for WordPress publishing: {str(e)}', + 'error': 'server_error' + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def bulk_publish_content(request) -> Response: + """ + Bulk publish multiple content items to WordPress + + POST /api/v1/content/bulk-publish-to-wordpress/ + + Body: + { + "content_ids": [1, 2, 3, 4], + "site_integration_id": 123, // Optional + "force": false // Optional + } + """ + try: + content_ids = request.data.get('content_ids', []) + site_integration_id = request.data.get('site_integration_id') + force = request.data.get('force', False) + + if not content_ids: + return Response( + { + 'success': False, + 'message': 'No content IDs provided', + 'error': 'missing_content_ids' + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check permissions + if not request.user.has_perm('content.change_contentpost'): + return Response( + { + 'success': False, + 'message': 'Permission denied', + 'error': 'insufficient_permissions' + }, + status=status.HTTP_403_FORBIDDEN + ) + + # Get site integration + if site_integration_id: + site_integration = get_object_or_404(SiteIntegration, id=site_integration_id) + else: + site_integration = SiteIntegration.objects.filter( + platform='wordpress', + is_active=True, + ).first() + + if not site_integration: + return Response( + { + 'success': False, + 'message': 'No WordPress integration found', + 'error': 'no_integration' + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Validate content items + content_items = ContentPost.objects.filter(id__in=content_ids) + + if content_items.count() != len(content_ids): + return Response( + { + 'success': False, + 'message': 'Some content items not found', + 'error': 'content_not_found' + }, + status=status.HTTP_404_NOT_FOUND + ) + + # Queue bulk publishing task + task_result = bulk_publish_content_to_wordpress.delay( + content_ids, + site_integration.id + ) + + return Response( + { + 'success': True, + 'message': f'{len(content_ids)} content items queued for WordPress publishing', + 'data': { + 'content_count': len(content_ids), + 'site_integration_id': site_integration.id, + 'task_id': task_result.id, + 'status': 'queued' + } + }, + status=status.HTTP_202_ACCEPTED + ) + + except Exception as e: + return Response( + { + 'success': False, + 'message': f'Error queuing bulk WordPress publishing: {str(e)}', + 'error': 'server_error' + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_wordpress_status(request, content_id: int) -> Response: + """ + Get WordPress publishing status for a content item + + GET /api/v1/content/{content_id}/wordpress-status/ + """ + try: + content = get_object_or_404(ContentPost, id=content_id) + + return Response( + { + 'success': True, + 'data': { + 'content_id': content.id, + 'wordpress_sync_status': content.wordpress_sync_status, + 'wordpress_post_id': content.wordpress_post_id, + 'wordpress_post_url': content.wordpress_post_url, + 'wordpress_sync_attempts': content.wordpress_sync_attempts, + 'last_wordpress_sync': content.last_wordpress_sync.isoformat() if content.last_wordpress_sync else None, + } + } + ) + + except Exception as e: + return Response( + { + 'success': False, + 'message': f'Error getting WordPress status: {str(e)}', + 'error': 'server_error' + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_wordpress_integrations(request) -> Response: + """ + Get available WordPress integrations for publishing + + GET /api/v1/wordpress-integrations/ + """ + try: + integrations = SiteIntegration.objects.filter( + platform='wordpress', + is_active=True, + # Add organization filter if applicable + ).values( + 'id', 'site_name', 'site_url', 'is_active', + 'created_at', 'last_sync_at' + ) + + return Response( + { + 'success': True, + 'data': list(integrations) + } + ) + + except Exception as e: + return Response( + { + 'success': False, + 'message': f'Error getting WordPress integrations: {str(e)}', + 'error': 'server_error' + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def retry_failed_wordpress_sync(request, content_id: int) -> Response: + """ + Retry a failed WordPress sync + + POST /api/v1/content/{content_id}/retry-wordpress-sync/ + """ + try: + content = get_object_or_404(ContentPost, id=content_id) + + if content.wordpress_sync_status != 'failed': + return Response( + { + 'success': False, + 'message': 'Content is not in failed status', + 'error': 'invalid_status' + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get default WordPress integration + site_integration = SiteIntegration.objects.filter( + platform='wordpress', + is_active=True, + ).first() + + if not site_integration: + return Response( + { + 'success': False, + 'message': 'No WordPress integration found', + 'error': 'no_integration' + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Reset status and retry + content.wordpress_sync_status = 'pending' + content.save(update_fields=['wordpress_sync_status']) + + # Get task_id if available + task_id = None + if hasattr(content, 'writer_task'): + task_id = content.writer_task.id + + # Queue the publishing task + task_result = publish_content_to_wordpress.delay( + content.id, + site_integration.id, + task_id + ) + + return Response( + { + 'success': True, + 'message': 'WordPress sync retry queued', + 'data': { + 'content_id': content.id, + 'task_id': task_result.id, + 'status': 'queued' + } + }, + status=status.HTTP_202_ACCEPTED + ) + + except Exception as e: + return Response( + { + 'success': False, + 'message': f'Error retrying WordPress sync: {str(e)}', + 'error': 'server_error' + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) \ No newline at end of file diff --git a/backend/igny8_core/celery.py b/backend/igny8_core/celery.py index 39274546..e864c785 100644 --- a/backend/igny8_core/celery.py +++ b/backend/igny8_core/celery.py @@ -29,6 +29,19 @@ app.conf.beat_schedule = { 'task': 'igny8_core.business.automation.tasks.execute_scheduled_automation_rules', 'schedule': crontab(minute='*/5'), # Every 5 minutes }, + # WordPress Publishing Tasks + 'process-pending-wordpress-publications': { + 'task': 'igny8_core.tasks.wordpress_publishing.process_pending_wordpress_publications', + 'schedule': crontab(minute='*/5'), # Every 5 minutes + }, + 'wordpress-status-reconciliation': { + 'task': 'igny8_core.tasks.wordpress_publishing.wordpress_status_reconciliation', + 'schedule': crontab(hour=2, minute=0), # Daily at 2 AM + }, + 'retry-failed-wordpress-publications': { + 'task': 'igny8_core.tasks.wordpress_publishing.retry_failed_wordpress_publications', + 'schedule': crontab(hour='*/6', minute=30), # Every 6 hours at :30 + }, } @app.task(bind=True, ignore_result=True) diff --git a/backend/igny8_core/migrations/0002_wordpress_sync_fields.py b/backend/igny8_core/migrations/0002_wordpress_sync_fields.py new file mode 100644 index 00000000..4521add8 --- /dev/null +++ b/backend/igny8_core/migrations/0002_wordpress_sync_fields.py @@ -0,0 +1,63 @@ +# Generated migration for WordPress sync fields + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0001_initial'), # Adjust based on your actual migration + ] + + operations = [ + migrations.AddField( + model_name='contentpost', + name='wordpress_sync_status', + field=models.CharField( + choices=[ + ('pending', 'Pending'), + ('syncing', 'Syncing'), + ('success', 'Success'), + ('failed', 'Failed') + ], + default='pending', + max_length=20, + help_text='Status of WordPress synchronization' + ), + ), + migrations.AddField( + model_name='contentpost', + name='wordpress_post_id', + field=models.PositiveIntegerField( + null=True, + blank=True, + help_text='WordPress post ID after successful sync' + ), + ), + migrations.AddField( + model_name='contentpost', + name='wordpress_post_url', + field=models.URLField( + null=True, + blank=True, + help_text='WordPress post URL after successful sync' + ), + ), + migrations.AddField( + model_name='contentpost', + name='wordpress_sync_attempts', + field=models.PositiveSmallIntegerField( + default=0, + help_text='Number of WordPress sync attempts' + ), + ), + migrations.AddField( + model_name='contentpost', + name='last_wordpress_sync', + field=models.DateTimeField( + null=True, + blank=True, + help_text='Timestamp of last WordPress sync attempt' + ), + ), + ] \ No newline at end of file diff --git a/backend/igny8_core/tasks/wordpress_publishing.py b/backend/igny8_core/tasks/wordpress_publishing.py new file mode 100644 index 00000000..a564d8e5 --- /dev/null +++ b/backend/igny8_core/tasks/wordpress_publishing.py @@ -0,0 +1,385 @@ +""" +IGNY8 Content Publishing Celery Tasks + +Handles automated publishing of content from IGNY8 to WordPress sites. +""" +from celery import shared_task +from django.conf import settings +from django.utils import timezone +from datetime import timedelta +import requests +import logging +from typing import Dict, List, Any, Optional + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, max_retries=3) +def publish_content_to_wordpress(self, content_id: int, site_integration_id: int, task_id: Optional[int] = None) -> Dict[str, Any]: + """ + Publish a single content item to WordPress + + Args: + content_id: IGNY8 content ID + site_integration_id: WordPress site integration ID + task_id: Optional IGNY8 task ID + + Returns: + Dict with success status and details + """ + try: + from igny8_core.models import ContentPost, SiteIntegration + + # Get content and site integration + try: + content = ContentPost.objects.get(id=content_id) + site_integration = SiteIntegration.objects.get(id=site_integration_id) + except (ContentPost.DoesNotExist, SiteIntegration.DoesNotExist) as e: + logger.error(f"Content or site integration not found: {e}") + return {"success": False, "error": str(e)} + + # Check if content is ready for publishing + if content.wordpress_sync_status == 'success': + logger.info(f"Content {content_id} already published to WordPress") + return {"success": True, "message": "Already published", "wordpress_post_id": content.wordpress_post_id} + + if content.wordpress_sync_status == 'syncing': + logger.info(f"Content {content_id} is currently syncing") + return {"success": False, "error": "Content is currently syncing"} + + # Update status to syncing + content.wordpress_sync_status = 'syncing' + content.save(update_fields=['wordpress_sync_status']) + + # Prepare content data for WordPress + content_data = { + 'content_id': content.id, + 'task_id': task_id, + 'title': content.title, + 'content_html': content.content_html or content.content, + 'excerpt': content.brief or '', + 'status': 'publish', + 'author_email': content.author.email if content.author else None, + 'author_name': content.author.get_full_name() if content.author else None, + 'published_at': content.published_at.isoformat() if content.published_at else None, + 'seo_title': getattr(content, 'seo_title', ''), + 'seo_description': getattr(content, 'seo_description', ''), + 'featured_image_url': content.featured_image.url if content.featured_image else None, + 'sectors': [{'id': s.id, 'name': s.name} for s in content.sectors.all()], + 'clusters': [{'id': c.id, 'name': c.name} for c in content.clusters.all()], + 'tags': getattr(content, 'tags', []), + 'focus_keywords': getattr(content, 'focus_keywords', []) + } + + # Call WordPress REST API + wordpress_url = f"{site_integration.site_url}/wp-json/igny8/v1/publish-content/" + headers = { + 'Content-Type': 'application/json', + 'X-IGNY8-API-KEY': site_integration.api_key, + } + + response = requests.post( + wordpress_url, + json=content_data, + headers=headers, + timeout=30 + ) + + if response.status_code == 201: + # Success + wp_data = response.json().get('data', {}) + content.wordpress_sync_status = 'success' + content.wordpress_post_id = wp_data.get('post_id') + content.wordpress_post_url = wp_data.get('post_url') + content.last_wordpress_sync = timezone.now() + content.save(update_fields=[ + 'wordpress_sync_status', 'wordpress_post_id', + 'wordpress_post_url', 'last_wordpress_sync' + ]) + + logger.info(f"Successfully published content {content_id} to WordPress post {content.wordpress_post_id}") + return { + "success": True, + "wordpress_post_id": content.wordpress_post_id, + "wordpress_post_url": content.wordpress_post_url + } + + elif response.status_code == 409: + # Content already exists + wp_data = response.json().get('data', {}) + content.wordpress_sync_status = 'success' + content.wordpress_post_id = wp_data.get('post_id') + content.last_wordpress_sync = timezone.now() + content.save(update_fields=[ + 'wordpress_sync_status', 'wordpress_post_id', 'last_wordpress_sync' + ]) + + logger.info(f"Content {content_id} already exists on WordPress") + return {"success": True, "message": "Content already exists", "wordpress_post_id": content.wordpress_post_id} + + else: + # Error + error_msg = f"WordPress API error: {response.status_code} - {response.text}" + logger.error(error_msg) + + # Retry logic + if self.request.retries < self.max_retries: + content.wordpress_sync_attempts = (content.wordpress_sync_attempts or 0) + 1 + content.save(update_fields=['wordpress_sync_attempts']) + + # Exponential backoff: 1min, 5min, 15min + countdown = 60 * (5 ** self.request.retries) + raise self.retry(countdown=countdown, exc=Exception(error_msg)) + else: + # Max retries reached + content.wordpress_sync_status = 'failed' + content.last_wordpress_sync = timezone.now() + content.save(update_fields=['wordpress_sync_status', 'last_wordpress_sync']) + + return {"success": False, "error": error_msg} + + except Exception as e: + logger.error(f"Error publishing content {content_id}: {str(e)}") + + # Update content status on error + try: + content = ContentPost.objects.get(id=content_id) + content.wordpress_sync_status = 'failed' + content.last_wordpress_sync = timezone.now() + content.save(update_fields=['wordpress_sync_status', 'last_wordpress_sync']) + except: + pass + + return {"success": False, "error": str(e)} + + +@shared_task +def process_pending_wordpress_publications() -> Dict[str, Any]: + """ + Process all content items pending WordPress publication + Runs every 5 minutes + """ + try: + from igny8_core.models import ContentPost, SiteIntegration + + # Find content marked for WordPress publishing + pending_content = ContentPost.objects.filter( + wordpress_sync_status='pending', + published_at__isnull=False # Only published content + ).select_related('author').prefetch_related('sectors', 'clusters') + + if not pending_content.exists(): + logger.info("No content pending WordPress publication") + return {"success": True, "processed": 0} + + # Get active WordPress integrations + active_integrations = SiteIntegration.objects.filter( + platform='wordpress', + is_active=True, + api_key__isnull=False + ) + + if not active_integrations.exists(): + logger.warning("No active WordPress integrations found") + return {"success": False, "error": "No active WordPress integrations"} + + processed = 0 + failed = 0 + + for content in pending_content[:50]: # Process max 50 at a time + for integration in active_integrations: + # Get task_id if content is associated with a task + task_id = None + if hasattr(content, 'writer_task'): + task_id = content.writer_task.id + + # Queue individual publish task + publish_content_to_wordpress.delay( + content.id, + integration.id, + task_id + ) + processed += 1 + + logger.info(f"Queued {processed} content items for WordPress publication") + return {"success": True, "processed": processed, "failed": failed} + + except Exception as e: + logger.error(f"Error processing pending WordPress publications: {str(e)}") + return {"success": False, "error": str(e)} + + +@shared_task +def bulk_publish_content_to_wordpress(content_ids: List[int], site_integration_id: int) -> Dict[str, Any]: + """ + Bulk publish multiple content items to WordPress + Used for manual bulk operations from Content Manager + """ + try: + from igny8_core.models import ContentPost, SiteIntegration + + site_integration = SiteIntegration.objects.get(id=site_integration_id) + content_items = ContentPost.objects.filter(id__in=content_ids) + + results = { + "success": True, + "total": len(content_ids), + "queued": 0, + "skipped": 0, + "errors": [] + } + + for content in content_items: + try: + # Skip if already published or syncing + if content.wordpress_sync_status in ['success', 'syncing']: + results["skipped"] += 1 + continue + + # Mark as pending and queue + content.wordpress_sync_status = 'pending' + content.save(update_fields=['wordpress_sync_status']) + + # Get task_id if available + task_id = None + if hasattr(content, 'writer_task'): + task_id = content.writer_task.id + + # Queue individual publish task + publish_content_to_wordpress.delay( + content.id, + site_integration.id, + task_id + ) + results["queued"] += 1 + + except Exception as e: + results["errors"].append(f"Content {content.id}: {str(e)}") + + if results["errors"]: + results["success"] = len(results["errors"]) < results["total"] / 2 # Success if < 50% errors + + logger.info(f"Bulk publish: {results['queued']} queued, {results['skipped']} skipped, {len(results['errors'])} errors") + return results + + except Exception as e: + logger.error(f"Error in bulk publish: {str(e)}") + return {"success": False, "error": str(e)} + + +@shared_task +def wordpress_status_reconciliation() -> Dict[str, Any]: + """ + Daily task to reconcile status between IGNY8 and WordPress + Checks for discrepancies and fixes them + """ + try: + from igny8_core.models import ContentPost, SiteIntegration + + # Get content marked as published to WordPress + wp_content = ContentPost.objects.filter( + wordpress_sync_status='success', + wordpress_post_id__isnull=False + ) + + active_integrations = SiteIntegration.objects.filter( + platform='wordpress', + is_active=True + ) + + reconciled = 0 + errors = [] + + for integration in active_integrations: + integration_content = wp_content.filter( + # Assuming there's a way to link content to integration + # This would depend on your data model + ) + + for content in integration_content[:100]: # Limit to prevent timeouts + try: + # Check WordPress post status + wp_url = f"{integration.site_url}/wp-json/igny8/v1/post-status/{content.id}/" + headers = {'X-IGNY8-API-KEY': integration.api_key} + + response = requests.get(wp_url, headers=headers, timeout=10) + + if response.status_code == 200: + wp_data = response.json().get('data', {}) + wp_status = wp_data.get('wordpress_status') + + # Update if status changed + if wp_status == 'trash' and content.wordpress_sync_status == 'success': + content.wordpress_sync_status = 'failed' + content.save(update_fields=['wordpress_sync_status']) + reconciled += 1 + + elif response.status_code == 404: + # Post not found on WordPress + content.wordpress_sync_status = 'failed' + content.wordpress_post_id = None + content.wordpress_post_url = None + content.save(update_fields=[ + 'wordpress_sync_status', 'wordpress_post_id', 'wordpress_post_url' + ]) + reconciled += 1 + + except Exception as e: + errors.append(f"Content {content.id}: {str(e)}") + + logger.info(f"Status reconciliation: {reconciled} reconciled, {len(errors)} errors") + return {"success": True, "reconciled": reconciled, "errors": errors} + + except Exception as e: + logger.error(f"Error in status reconciliation: {str(e)}") + return {"success": False, "error": str(e)} + + +@shared_task +def retry_failed_wordpress_publications() -> Dict[str, Any]: + """ + Retry failed WordPress publications (runs daily) + Only retries items that failed more than 1 hour ago + """ + try: + from igny8_core.models import ContentPost, SiteIntegration + + # Find failed publications older than 1 hour + one_hour_ago = timezone.now() - timedelta(hours=1) + failed_content = ContentPost.objects.filter( + wordpress_sync_status='failed', + last_wordpress_sync__lt=one_hour_ago, + wordpress_sync_attempts__lt=5 # Max 5 total attempts + ) + + active_integrations = SiteIntegration.objects.filter( + platform='wordpress', + is_active=True + ) + + retried = 0 + + for content in failed_content[:20]: # Limit retries per run + for integration in active_integrations: + # Reset status and retry + content.wordpress_sync_status = 'pending' + content.save(update_fields=['wordpress_sync_status']) + + task_id = None + if hasattr(content, 'writer_task'): + task_id = content.writer_task.id + + publish_content_to_wordpress.delay( + content.id, + integration.id, + task_id + ) + retried += 1 + break # Only retry with first active integration + + logger.info(f"Retried {retried} failed WordPress publications") + return {"success": True, "retried": retried} + + except Exception as e: + logger.error(f"Error retrying failed publications: {str(e)}") + return {"success": False, "error": str(e)} \ No newline at end of file diff --git a/backend/igny8_core/urls/wordpress_publishing.py b/backend/igny8_core/urls/wordpress_publishing.py new file mode 100644 index 00000000..4a6e6765 --- /dev/null +++ b/backend/igny8_core/urls/wordpress_publishing.py @@ -0,0 +1,38 @@ +""" +URL configuration for WordPress publishing endpoints +""" +from django.urls import path +from igny8_core.api.wordpress_publishing import ( + publish_single_content, + bulk_publish_content, + get_wordpress_status, + get_wordpress_integrations, + retry_failed_wordpress_sync, +) + +urlpatterns = [ + # Single content publishing + path('content//publish-to-wordpress/', + publish_single_content, + name='publish_single_content'), + + # Bulk content publishing + path('content/bulk-publish-to-wordpress/', + bulk_publish_content, + name='bulk_publish_content'), + + # WordPress status + path('content//wordpress-status/', + get_wordpress_status, + name='get_wordpress_status'), + + # WordPress integrations list + path('wordpress-integrations/', + get_wordpress_integrations, + name='get_wordpress_integrations'), + + # Retry failed sync + path('content//retry-wordpress-sync/', + retry_failed_wordpress_sync, + name='retry_failed_wordpress_sync'), +] \ No newline at end of file diff --git a/frontend/src/components/BulkWordPressPublish/BulkWordPressPublish.tsx b/frontend/src/components/BulkWordPressPublish/BulkWordPressPublish.tsx new file mode 100644 index 00000000..587cd476 --- /dev/null +++ b/frontend/src/components/BulkWordPressPublish/BulkWordPressPublish.tsx @@ -0,0 +1,284 @@ +import React, { useState } from 'react'; +import { + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Typography, + Box, + LinearProgress, + Alert, + List, + ListItem, + ListItemText, + ListItemIcon, + CircularProgress, + Chip +} from '@mui/material'; +import { + Publish as PublishIcon, + CheckCircle as SuccessIcon, + Error as ErrorIcon, + Schedule as PendingIcon +} from '@mui/icons-material'; +import { api } from '../../services/api'; + +interface BulkWordPressPublishProps { + selectedContentIds: string[]; + contentItems: Array<{ + id: string; + title: string; + status: string; + }>; + onPublishComplete: () => void; + onClose: () => void; +} + +interface BulkPublishResult { + total: number; + queued: number; + skipped: number; + errors: string[]; +} + +export const BulkWordPressPublish: React.FC = ({ + selectedContentIds, + contentItems, + onPublishComplete, + onClose +}) => { + const [open, setOpen] = useState(false); + const [publishing, setPublishing] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const selectedItems = contentItems.filter(item => + selectedContentIds.includes(item.id) + ); + + const handleBulkPublish = async () => { + setPublishing(true); + setError(null); + setResult(null); + + try { + const response = await api.post('/api/v1/content/bulk-publish-to-wordpress/', { + content_ids: selectedContentIds.map(id => parseInt(id)) + }); + + if (response.data.success) { + setResult({ + total: selectedContentIds.length, + queued: response.data.data.content_count, + skipped: 0, + errors: [] + }); + + // Start polling for individual status updates + startStatusPolling(); + } else { + setError(response.data.message || 'Failed to start bulk publishing'); + } + } catch (error: any) { + setError(error.response?.data?.message || 'Error starting bulk publish'); + } finally { + setPublishing(false); + } + }; + + const startStatusPolling = () => { + // Poll for 2 minutes to check status + const pollInterval = setInterval(async () => { + try { + // Check status of all items (this could be optimized with a dedicated endpoint) + const statusPromises = selectedContentIds.map(id => + api.get(`/api/v1/content/${id}/wordpress-status/`) + ); + + const responses = await Promise.allSettled(statusPromises); + + let completedCount = 0; + let successCount = 0; + let failedCount = 0; + + responses.forEach((response) => { + if (response.status === 'fulfilled' && response.value.data.success) { + const status = response.value.data.data.wordpress_sync_status; + if (status === 'success' || status === 'failed') { + completedCount++; + if (status === 'success') successCount++; + if (status === 'failed') failedCount++; + } + } + }); + + // If all items are complete, stop polling + if (completedCount === selectedContentIds.length) { + clearInterval(pollInterval); + setResult(prev => prev ? { + ...prev, + queued: successCount, + errors: Array(failedCount).fill('Publishing failed') + } : null); + onPublishComplete(); + } + } catch (error) { + console.error('Error polling status:', error); + } + }, 5000); + + // Stop polling after 2 minutes + setTimeout(() => { + clearInterval(pollInterval); + }, 120000); + }; + + const handleOpen = () => { + setOpen(true); + setResult(null); + setError(null); + }; + + const handleClose = () => { + setOpen(false); + onClose(); + }; + + const getResultSummary = () => { + if (!result) return null; + + const { total, queued, skipped, errors } = result; + const failed = errors.length; + + return ( + + + Bulk Publish Results + + + + } + label={`${queued} Queued`} + color="success" + size="small" + /> + {skipped > 0 && ( + } + label={`${skipped} Skipped`} + color="warning" + size="small" + /> + )} + {failed > 0 && ( + } + label={`${failed} Failed`} + color="error" + size="small" + /> + )} + + + {failed > 0 && ( + + Some items failed to publish. Check individual item status for details. + + )} + + ); + }; + + return ( + <> + + + + + Bulk Publish to WordPress + + + + {!publishing && !result && ( + <> + + You are about to publish {selectedContentIds.length} content items to WordPress: + + + + {selectedItems.map((item) => ( + + + + + + + ))} + + + + This will create new posts on your WordPress site with all content, + images, categories, and SEO metadata. Items already published will be skipped. + + + )} + + {publishing && ( + + + + Queuing content for WordPress publishing... + + + + )} + + {result && getResultSummary()} + + {error && ( + + {error} + + )} + + + + + + {!publishing && !result && ( + + )} + + + + ); +}; + +export default BulkWordPressPublish; \ No newline at end of file diff --git a/frontend/src/components/WordPressPublish/WordPressPublish.tsx b/frontend/src/components/WordPressPublish/WordPressPublish.tsx new file mode 100644 index 00000000..b1d369fc --- /dev/null +++ b/frontend/src/components/WordPressPublish/WordPressPublish.tsx @@ -0,0 +1,338 @@ +import React, { useState, useEffect } from 'react'; +import { + Button, + Alert, + Chip, + IconButton, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + CircularProgress, + Box, + Typography +} from '@mui/material'; +import { + Publish as PublishIcon, + Refresh as RefreshIcon, + CheckCircle as SuccessIcon, + Error as ErrorIcon, + Schedule as PendingIcon, + Sync as SyncingIcon +} from '@mui/icons-material'; +import { api } from '../../services/api'; + +interface WordPressPublishProps { + contentId: string; + contentTitle: string; + currentStatus?: 'draft' | 'publishing' | 'published' | 'failed'; + onStatusChange?: (status: string) => void; + size?: 'small' | 'medium' | 'large'; +} + +interface WordPressStatus { + wordpress_sync_status: 'pending' | 'syncing' | 'success' | 'failed'; + wordpress_post_id?: number; + wordpress_post_url?: string; + wordpress_sync_attempts: number; + last_wordpress_sync?: string; +} + +export const WordPressPublish: React.FC = ({ + contentId, + contentTitle, + currentStatus = 'draft', + onStatusChange, + size = 'medium' +}) => { + const [wpStatus, setWpStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [publishDialogOpen, setPublishDialogOpen] = useState(false); + const [error, setError] = useState(null); + + // Fetch current WordPress status + const fetchWordPressStatus = async () => { + try { + const response = await api.get(`/api/v1/content/${contentId}/wordpress-status/`); + if (response.data.success) { + setWpStatus(response.data.data); + } + } catch (error) { + console.error('Failed to fetch WordPress status:', error); + } + }; + + useEffect(() => { + fetchWordPressStatus(); + }, [contentId]); + + // Handle publish to WordPress + const handlePublishToWordPress = async (force: boolean = false) => { + setLoading(true); + setError(null); + + try { + const response = await api.post(`/api/v1/content/${contentId}/publish-to-wordpress/`, { + force: force + }); + + if (response.data.success) { + setWpStatus(prev => prev ? { ...prev, wordpress_sync_status: 'pending' } : null); + onStatusChange?.('publishing'); + + // Poll for status updates + pollForStatusUpdate(); + } else { + setError(response.data.message || 'Failed to publish to WordPress'); + } + } catch (error: any) { + setError(error.response?.data?.message || 'Error publishing to WordPress'); + } finally { + setLoading(false); + setPublishDialogOpen(false); + } + }; + + // Poll for status updates after publishing + const pollForStatusUpdate = () => { + const pollInterval = setInterval(async () => { + try { + const response = await api.get(`/api/v1/content/${contentId}/wordpress-status/`); + if (response.data.success) { + const status = response.data.data; + setWpStatus(status); + + // Stop polling if sync is complete (success or failed) + if (status.wordpress_sync_status === 'success' || status.wordpress_sync_status === 'failed') { + clearInterval(pollInterval); + onStatusChange?.(status.wordpress_sync_status === 'success' ? 'published' : 'failed'); + } + } + } catch (error) { + clearInterval(pollInterval); + } + }, 3000); // Poll every 3 seconds + + // Stop polling after 2 minutes + setTimeout(() => { + clearInterval(pollInterval); + }, 120000); + }; + + // Handle retry + const handleRetry = async () => { + setLoading(true); + setError(null); + + try { + const response = await api.post(`/api/v1/content/${contentId}/retry-wordpress-sync/`); + if (response.data.success) { + setWpStatus(prev => prev ? { ...prev, wordpress_sync_status: 'pending' } : null); + onStatusChange?.('publishing'); + pollForStatusUpdate(); + } else { + setError(response.data.message || 'Failed to retry WordPress sync'); + } + } catch (error: any) { + setError(error.response?.data?.message || 'Error retrying WordPress sync'); + } finally { + setLoading(false); + } + }; + + // Get status display info + const getStatusInfo = () => { + if (!wpStatus) { + return { + color: 'default' as const, + icon: , + label: 'Not Published', + action: 'publish' + }; + } + + switch (wpStatus.wordpress_sync_status) { + case 'pending': + return { + color: 'warning' as const, + icon: , + label: 'Queued', + action: 'wait' + }; + case 'syncing': + return { + color: 'info' as const, + icon: , + label: 'Publishing...', + action: 'wait' + }; + case 'success': + return { + color: 'success' as const, + icon: , + label: 'Published', + action: 'view' + }; + case 'failed': + return { + color: 'error' as const, + icon: , + label: 'Failed', + action: 'retry' + }; + default: + return { + color: 'default' as const, + icon: , + label: 'Not Published', + action: 'publish' + }; + } + }; + + const statusInfo = getStatusInfo(); + + const renderButton = () => { + if (size === 'small') { + return ( + + { + if (statusInfo.action === 'publish') { + setPublishDialogOpen(true); + } else if (statusInfo.action === 'retry') { + handleRetry(); + } else if (statusInfo.action === 'view' && wpStatus?.wordpress_post_url) { + window.open(wpStatus.wordpress_post_url, '_blank'); + } + }} + disabled={loading || statusInfo.action === 'wait'} + color={statusInfo.color} + > + {loading ? : statusInfo.icon} + + + ); + } + + return ( + + ); + }; + + const renderStatusChip = () => { + if (size === 'small') return null; + + return ( + { + if (wpStatus?.wordpress_post_url) { + window.open(wpStatus.wordpress_post_url, '_blank'); + } + }} + style={{ + marginLeft: 8, + cursor: wpStatus?.wordpress_post_url ? 'pointer' : 'default' + }} + /> + ); + }; + + return ( + + {renderButton()} + {renderStatusChip()} + + {error && ( + + {error} + setError(null)} + sx={{ ml: 1 }} + > + × + + + )} + + {/* Publish Confirmation Dialog */} + setPublishDialogOpen(false)} + maxWidth="sm" + fullWidth + > + Publish to WordPress + + + Are you sure you want to publish "{contentTitle}" to WordPress? + + + + This will create a new post on your connected WordPress site with all content, + images, categories, and SEO metadata. + + + {wpStatus?.wordpress_sync_status === 'success' && ( + + This content is already published to WordPress. + You can force republish to update the existing post. + + )} + + + + {wpStatus?.wordpress_sync_status === 'success' && ( + + )} + + + + + ); +}; + +export default WordPressPublish; \ No newline at end of file