igny8-wp
This commit is contained in:
400
backend/igny8_core/api/wordpress_publishing.py
Normal file
400
backend/igny8_core/api/wordpress_publishing.py
Normal file
@@ -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
|
||||||
|
)
|
||||||
@@ -29,6 +29,19 @@ app.conf.beat_schedule = {
|
|||||||
'task': 'igny8_core.business.automation.tasks.execute_scheduled_automation_rules',
|
'task': 'igny8_core.business.automation.tasks.execute_scheduled_automation_rules',
|
||||||
'schedule': crontab(minute='*/5'), # Every 5 minutes
|
'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)
|
@app.task(bind=True, ignore_result=True)
|
||||||
|
|||||||
63
backend/igny8_core/migrations/0002_wordpress_sync_fields.py
Normal file
63
backend/igny8_core/migrations/0002_wordpress_sync_fields.py
Normal file
@@ -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'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
385
backend/igny8_core/tasks/wordpress_publishing.py
Normal file
385
backend/igny8_core/tasks/wordpress_publishing.py
Normal file
@@ -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)}
|
||||||
38
backend/igny8_core/urls/wordpress_publishing.py
Normal file
38
backend/igny8_core/urls/wordpress_publishing.py
Normal file
@@ -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/<int:content_id>/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/<int:content_id>/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/<int:content_id>/retry-wordpress-sync/',
|
||||||
|
retry_failed_wordpress_sync,
|
||||||
|
name='retry_failed_wordpress_sync'),
|
||||||
|
]
|
||||||
@@ -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<BulkWordPressPublishProps> = ({
|
||||||
|
selectedContentIds,
|
||||||
|
contentItems,
|
||||||
|
onPublishComplete,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [result, setResult] = useState<BulkPublishResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Bulk Publish Results
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box display="flex" gap={1} flexWrap="wrap" mb={2}>
|
||||||
|
<Chip
|
||||||
|
icon={<SuccessIcon />}
|
||||||
|
label={`${queued} Queued`}
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
{skipped > 0 && (
|
||||||
|
<Chip
|
||||||
|
icon={<PendingIcon />}
|
||||||
|
label={`${skipped} Skipped`}
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{failed > 0 && (
|
||||||
|
<Chip
|
||||||
|
icon={<ErrorIcon />}
|
||||||
|
label={`${failed} Failed`}
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{failed > 0 && (
|
||||||
|
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||||
|
Some items failed to publish. Check individual item status for details.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<PublishIcon />}
|
||||||
|
onClick={handleOpen}
|
||||||
|
disabled={selectedContentIds.length === 0}
|
||||||
|
>
|
||||||
|
Bulk Publish to WordPress ({selectedContentIds.length})
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
Bulk Publish to WordPress
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
{!publishing && !result && (
|
||||||
|
<>
|
||||||
|
<Typography variant="body1" gutterBottom>
|
||||||
|
You are about to publish {selectedContentIds.length} content items to WordPress:
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<List dense sx={{ maxHeight: 300, overflow: 'auto', mt: 2 }}>
|
||||||
|
{selectedItems.map((item) => (
|
||||||
|
<ListItem key={item.id}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<PublishIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.title}
|
||||||
|
secondary={`Status: ${item.status}`}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Alert severity="info" sx={{ mt: 2 }}>
|
||||||
|
This will create new posts on your WordPress site with all content,
|
||||||
|
images, categories, and SEO metadata. Items already published will be skipped.
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{publishing && (
|
||||||
|
<Box sx={{ py: 3 }}>
|
||||||
|
<Box display="flex" alignItems="center" gap={2} mb={2}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
<Typography>Queuing content for WordPress publishing...</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && getResultSummary()}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose}>
|
||||||
|
{result ? 'Close' : 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!publishing && !result && (
|
||||||
|
<Button
|
||||||
|
onClick={handleBulkPublish}
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
disabled={selectedContentIds.length === 0}
|
||||||
|
>
|
||||||
|
Publish All to WordPress
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BulkWordPressPublish;
|
||||||
338
frontend/src/components/WordPressPublish/WordPressPublish.tsx
Normal file
338
frontend/src/components/WordPressPublish/WordPressPublish.tsx
Normal file
@@ -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<WordPressPublishProps> = ({
|
||||||
|
contentId,
|
||||||
|
contentTitle,
|
||||||
|
currentStatus = 'draft',
|
||||||
|
onStatusChange,
|
||||||
|
size = 'medium'
|
||||||
|
}) => {
|
||||||
|
const [wpStatus, setWpStatus] = useState<WordPressStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [publishDialogOpen, setPublishDialogOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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: <PublishIcon />,
|
||||||
|
label: 'Not Published',
|
||||||
|
action: 'publish'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (wpStatus.wordpress_sync_status) {
|
||||||
|
case 'pending':
|
||||||
|
return {
|
||||||
|
color: 'warning' as const,
|
||||||
|
icon: <PendingIcon />,
|
||||||
|
label: 'Queued',
|
||||||
|
action: 'wait'
|
||||||
|
};
|
||||||
|
case 'syncing':
|
||||||
|
return {
|
||||||
|
color: 'info' as const,
|
||||||
|
icon: <SyncingIcon className="animate-spin" />,
|
||||||
|
label: 'Publishing...',
|
||||||
|
action: 'wait'
|
||||||
|
};
|
||||||
|
case 'success':
|
||||||
|
return {
|
||||||
|
color: 'success' as const,
|
||||||
|
icon: <SuccessIcon />,
|
||||||
|
label: 'Published',
|
||||||
|
action: 'view'
|
||||||
|
};
|
||||||
|
case 'failed':
|
||||||
|
return {
|
||||||
|
color: 'error' as const,
|
||||||
|
icon: <ErrorIcon />,
|
||||||
|
label: 'Failed',
|
||||||
|
action: 'retry'
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
color: 'default' as const,
|
||||||
|
icon: <PublishIcon />,
|
||||||
|
label: 'Not Published',
|
||||||
|
action: 'publish'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusInfo = getStatusInfo();
|
||||||
|
|
||||||
|
const renderButton = () => {
|
||||||
|
if (size === 'small') {
|
||||||
|
return (
|
||||||
|
<Tooltip title={`WordPress: ${statusInfo.label}`}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
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 ? <CircularProgress size={16} /> : statusInfo.icon}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={statusInfo.action === 'publish' ? 'contained' : 'outlined'}
|
||||||
|
color={statusInfo.color}
|
||||||
|
startIcon={loading ? <CircularProgress size={20} /> : statusInfo.icon}
|
||||||
|
onClick={() => {
|
||||||
|
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'}
|
||||||
|
size={size}
|
||||||
|
>
|
||||||
|
{statusInfo.action === 'publish' && 'Publish to WordPress'}
|
||||||
|
{statusInfo.action === 'retry' && 'Retry'}
|
||||||
|
{statusInfo.action === 'view' && 'View on WordPress'}
|
||||||
|
{statusInfo.action === 'wait' && statusInfo.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStatusChip = () => {
|
||||||
|
if (size === 'small') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
icon={statusInfo.icon}
|
||||||
|
label={statusInfo.label}
|
||||||
|
color={statusInfo.color}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
if (wpStatus?.wordpress_post_url) {
|
||||||
|
window.open(wpStatus.wordpress_post_url, '_blank');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
cursor: wpStatus?.wordpress_post_url ? 'pointer' : 'default'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
{renderButton()}
|
||||||
|
{renderStatusChip()}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mt: 1 }}>
|
||||||
|
{error}
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</IconButton>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Publish Confirmation Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={publishDialogOpen}
|
||||||
|
onClose={() => setPublishDialogOpen(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Publish to WordPress</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body1" gutterBottom>
|
||||||
|
Are you sure you want to publish "<strong>{contentTitle}</strong>" to WordPress?
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ mt: 2 }}>
|
||||||
|
This will create a new post on your connected WordPress site with all content,
|
||||||
|
images, categories, and SEO metadata.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{wpStatus?.wordpress_sync_status === 'success' && (
|
||||||
|
<Alert severity="info" sx={{ mt: 2 }}>
|
||||||
|
This content is already published to WordPress.
|
||||||
|
You can force republish to update the existing post.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setPublishDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{wpStatus?.wordpress_sync_status === 'success' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handlePublishToWordPress(true)}
|
||||||
|
color="warning"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Force Republish
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => handlePublishToWordPress(false)}
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Publishing...' : 'Publish'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WordPressPublish;
|
||||||
Reference in New Issue
Block a user