From 0340016932045c56281a6464e079df3f6b5e3cb9 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Thu, 1 Jan 2026 07:10:03 +0000 Subject: [PATCH] Section 3-8 - #MIgration Runs - Multiple Migfeat: Update publishing terminology and add publishing settings - Changed references from "WordPress" to "Site" across multiple components for consistency. - Introduced a new "Publishing" tab in Site Settings to manage automatic content approval and publishing behavior. - Added publishing settings model to the backend with fields for auto-approval, auto-publish, and publishing limits. - Implemented Celery tasks for scheduling and processing automated content publishing. - Enhanced Writer Dashboard to include metrics for content published to the site and scheduled for publishing. --- .../automation/services/automation_service.py | 72 +++- backend/igny8_core/business/content/models.py | 27 ++ .../0003_add_publishing_settings.py | 38 ++ .../igny8_core/business/integration/models.py | 97 +++++ backend/igny8_core/celery.py | 12 + .../igny8_core/modules/integration/urls.py | 12 +- .../igny8_core/modules/integration/views.py | 143 +++++++ .../0015_add_publishing_scheduler_fields.py | 49 +++ .../igny8_core/modules/writer/serializers.py | 5 +- backend/igny8_core/tasks/__init__.py | 11 + .../igny8_core/tasks/publishing_scheduler.py | 361 ++++++++++++++++++ .../igny8_core/tasks/wordpress_publishing.py | 18 +- .../BulkWordPressPublish.tsx | 6 +- .../WordPressPublish/BulkWordPressPublish.tsx | 4 +- .../WordPressPublish/ContentActionsMenu.tsx | 2 +- .../WordPressPublish/WordPressPublish.tsx | 16 +- frontend/src/config/pages/review.config.tsx | 2 +- .../src/config/pages/table-actions.config.tsx | 12 +- frontend/src/pages/Sites/Settings.tsx | 324 +++++++++++++++- frontend/src/pages/Writer/Dashboard.tsx | 21 +- frontend/src/services/api.ts | 4 + 21 files changed, 1200 insertions(+), 36 deletions(-) create mode 100644 backend/igny8_core/business/integration/migrations/0003_add_publishing_settings.py create mode 100644 backend/igny8_core/modules/writer/migrations/0015_add_publishing_scheduler_fields.py create mode 100644 backend/igny8_core/tasks/__init__.py create mode 100644 backend/igny8_core/tasks/publishing_scheduler.py diff --git a/backend/igny8_core/business/automation/services/automation_service.py b/backend/igny8_core/business/automation/services/automation_service.py index 87923471..45e3c607 100644 --- a/backend/igny8_core/business/automation/services/automation_service.py +++ b/backend/igny8_core/business/automation/services/automation_service.py @@ -1476,11 +1476,36 @@ class AutomationService: This stage automatically approves content in 'review' status and marks it as 'approved' (ready for publishing to WordPress). + + Respects PublishingSettings: + - If auto_approval_enabled is False, skip approval and keep content in 'review' """ stage_number = 7 stage_name = "Review → Approved" start_time = time.time() + # Check publishing settings for auto-approval + from igny8_core.business.integration.models import PublishingSettings + publishing_settings, _ = PublishingSettings.get_or_create_for_site(self.site) + + if not publishing_settings.auto_approval_enabled: + self.logger.log_stage_progress( + self.run.run_id, self.account.id, self.site.id, + stage_number, "Auto-approval is disabled for this site - skipping Stage 7" + ) + self.run.stage_7_result = { + 'ready_for_review': 0, + 'approved_count': 0, + 'content_ids': [], + 'skipped': True, + 'reason': 'auto_approval_disabled' + } + self.run.status = 'completed' + self.run.completed_at = datetime.now() + self.run.save() + cache.delete(f'automation_lock_{self.site.id}') + return + # Query content ready for review ready_for_review = Content.objects.filter( site=self.site, @@ -1602,13 +1627,55 @@ class AutomationService: stage_number, approved_count, time_elapsed, 0 ) + # Check if auto-publish is enabled and queue approved content for publishing + published_count = 0 + if publishing_settings.auto_publish_enabled and approved_count > 0: + self.logger.log_stage_progress( + self.run.run_id, self.account.id, self.site.id, + stage_number, f"Auto-publish enabled - queuing {len(content_ids)} content items for publishing" + ) + + # Get WordPress integration for this site + from igny8_core.business.integration.models import SiteIntegration + wp_integration = SiteIntegration.objects.filter( + site=self.site, + platform='wordpress', + is_active=True + ).first() + + if wp_integration: + from igny8_core.tasks.wordpress_publishing import publish_content_to_wordpress + + for content_id in content_ids: + try: + # Queue publish task + publish_content_to_wordpress.delay( + content_id=content_id, + site_integration_id=wp_integration.id + ) + published_count += 1 + except Exception as e: + logger.error(f"[AutomationService] Failed to queue publish for content {content_id}: {str(e)}") + + self.logger.log_stage_progress( + self.run.run_id, self.account.id, self.site.id, + stage_number, f"Queued {published_count} content items for WordPress publishing" + ) + else: + self.logger.log_stage_progress( + self.run.run_id, self.account.id, self.site.id, + stage_number, "No active WordPress integration found - skipping auto-publish" + ) + self.run.stage_7_result = { 'ready_for_review': total_count, 'review_total': total_count, 'approved_count': approved_count, 'content_ids': content_ids, 'time_elapsed': time_elapsed, - 'in_progress': False + 'in_progress': False, + 'auto_published_count': published_count if publishing_settings.auto_publish_enabled else 0, + 'auto_publish_enabled': publishing_settings.auto_publish_enabled, } self.run.status = 'completed' self.run.completed_at = datetime.now() @@ -1617,7 +1684,8 @@ class AutomationService: # Release lock cache.delete(f'automation_lock_{self.site.id}') - logger.info(f"[AutomationService] Stage 7 complete: {approved_count} content pieces approved (ready for publishing)") + logger.info(f"[AutomationService] Stage 7 complete: {approved_count} content pieces approved" + + (f", {published_count} queued for publishing" if published_count > 0 else " (ready for publishing)")) def pause_automation(self): """Pause current automation run""" diff --git a/backend/igny8_core/business/content/models.py b/backend/igny8_core/business/content/models.py index 8597d436..30be9e8c 100644 --- a/backend/igny8_core/business/content/models.py +++ b/backend/igny8_core/business/content/models.py @@ -282,6 +282,33 @@ class Content(SoftDeletableModel, SiteSectorBaseModel): help_text="Content status" ) + # Publishing scheduler fields + SITE_STATUS_CHOICES = [ + ('not_published', 'Not Published'), + ('scheduled', 'Scheduled'), + ('publishing', 'Publishing'), + ('published', 'Published'), + ('failed', 'Failed'), + ] + site_status = models.CharField( + max_length=50, + choices=SITE_STATUS_CHOICES, + default='not_published', + db_index=True, + help_text="External site publishing status" + ) + scheduled_publish_at = models.DateTimeField( + null=True, + blank=True, + db_index=True, + help_text="Scheduled time for publishing to external site" + ) + site_status_updated_at = models.DateTimeField( + null=True, + blank=True, + help_text="Last time site_status was changed" + ) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/backend/igny8_core/business/integration/migrations/0003_add_publishing_settings.py b/backend/igny8_core/business/integration/migrations/0003_add_publishing_settings.py new file mode 100644 index 00000000..b37a17e4 --- /dev/null +++ b/backend/igny8_core/business/integration/migrations/0003_add_publishing_settings.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.9 on 2026-01-01 06:37 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('igny8_core_auth', '0018_add_country_remove_intent_seedkeyword'), + ('integration', '0002_add_sync_event_model'), + ] + + operations = [ + migrations.CreateModel( + name='PublishingSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('auto_approval_enabled', models.BooleanField(default=True, help_text="Automatically approve content after review (moves to 'approved' status)")), + ('auto_publish_enabled', models.BooleanField(default=True, help_text='Automatically publish approved content to the external site')), + ('daily_publish_limit', models.PositiveIntegerField(default=3, help_text='Maximum number of articles to publish per day', validators=[django.core.validators.MinValueValidator(1)])), + ('weekly_publish_limit', models.PositiveIntegerField(default=15, help_text='Maximum number of articles to publish per week', validators=[django.core.validators.MinValueValidator(1)])), + ('monthly_publish_limit', models.PositiveIntegerField(default=50, help_text='Maximum number of articles to publish per month', validators=[django.core.validators.MinValueValidator(1)])), + ('publish_days', models.JSONField(default=list, help_text='Days of the week to publish (mon, tue, wed, thu, fri, sat, sun)')), + ('publish_time_slots', models.JSONField(default=list, help_text="Times of day to publish (HH:MM format, e.g., ['09:00', '14:00', '18:00'])")), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')), + ('site', models.OneToOneField(help_text='Site these publishing settings belong to', on_delete=django.db.models.deletion.CASCADE, related_name='publishing_settings', to='igny8_core_auth.site')), + ], + options={ + 'verbose_name': 'Publishing Settings', + 'verbose_name_plural': 'Publishing Settings', + 'db_table': 'igny8_publishing_settings', + }, + ), + ] diff --git a/backend/igny8_core/business/integration/models.py b/backend/igny8_core/business/integration/models.py index 58ca37b9..1b065076 100644 --- a/backend/igny8_core/business/integration/models.py +++ b/backend/igny8_core/business/integration/models.py @@ -244,3 +244,100 @@ class SyncEvent(AccountBaseModel): def __str__(self): return f"{self.get_event_type_display()} - {self.description[:50]}" + +class PublishingSettings(AccountBaseModel): + """ + Site-level publishing configuration settings. + Controls automatic approval, publishing limits, and scheduling. + """ + + DEFAULT_PUBLISH_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri'] + DEFAULT_TIME_SLOTS = ['09:00', '14:00', '18:00'] + + site = models.OneToOneField( + 'igny8_core_auth.Site', + on_delete=models.CASCADE, + related_name='publishing_settings', + help_text="Site these publishing settings belong to" + ) + + # Auto-approval settings + auto_approval_enabled = models.BooleanField( + default=True, + help_text="Automatically approve content after review (moves to 'approved' status)" + ) + + # Auto-publish settings + auto_publish_enabled = models.BooleanField( + default=True, + help_text="Automatically publish approved content to the external site" + ) + + # Publishing limits + daily_publish_limit = models.PositiveIntegerField( + default=3, + validators=[MinValueValidator(1)], + help_text="Maximum number of articles to publish per day" + ) + + weekly_publish_limit = models.PositiveIntegerField( + default=15, + validators=[MinValueValidator(1)], + help_text="Maximum number of articles to publish per week" + ) + + monthly_publish_limit = models.PositiveIntegerField( + default=50, + validators=[MinValueValidator(1)], + help_text="Maximum number of articles to publish per month" + ) + + # Publishing schedule + publish_days = models.JSONField( + default=list, + help_text="Days of the week to publish (mon, tue, wed, thu, fri, sat, sun)" + ) + + publish_time_slots = models.JSONField( + default=list, + help_text="Times of day to publish (HH:MM format, e.g., ['09:00', '14:00', '18:00'])" + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = 'integration' + db_table = 'igny8_publishing_settings' + verbose_name = 'Publishing Settings' + verbose_name_plural = 'Publishing Settings' + + def __str__(self): + return f"Publishing Settings for {self.site.name}" + + def save(self, *args, **kwargs): + """Set defaults for JSON fields if empty""" + if not self.publish_days: + self.publish_days = self.DEFAULT_PUBLISH_DAYS + if not self.publish_time_slots: + self.publish_time_slots = self.DEFAULT_TIME_SLOTS + super().save(*args, **kwargs) + + @classmethod + def get_or_create_for_site(cls, site): + """Get or create publishing settings for a site with defaults""" + settings, created = cls.objects.get_or_create( + site=site, + defaults={ + 'account': site.account, + 'auto_approval_enabled': True, + 'auto_publish_enabled': True, + 'daily_publish_limit': 3, + 'weekly_publish_limit': 15, + 'monthly_publish_limit': 50, + 'publish_days': cls.DEFAULT_PUBLISH_DAYS, + 'publish_time_slots': cls.DEFAULT_TIME_SLOTS, + } + ) + return settings, created + diff --git a/backend/igny8_core/celery.py b/backend/igny8_core/celery.py index d9878493..da7cb3b2 100644 --- a/backend/igny8_core/celery.py +++ b/backend/igny8_core/celery.py @@ -19,6 +19,9 @@ app.config_from_object('django.conf:settings', namespace='CELERY') # Load task modules from all registered Django apps. app.autodiscover_tasks() +# Explicitly import tasks from igny8_core/tasks directory +app.autodiscover_tasks(['igny8_core.tasks']) + # Celery Beat schedule for periodic tasks app.conf.beat_schedule = { 'replenish-monthly-credits': { @@ -39,6 +42,15 @@ app.conf.beat_schedule = { 'task': 'automation.check_scheduled_automations', 'schedule': crontab(minute=0), # Every hour at :00 }, + # Publishing Scheduler Tasks + 'schedule-approved-content': { + 'task': 'publishing.schedule_approved_content', + 'schedule': crontab(minute=0), # Every hour at :00 + }, + 'process-scheduled-publications': { + 'task': 'publishing.process_scheduled_publications', + 'schedule': crontab(minute='*/5'), # Every 5 minutes + }, # Maintenance: purge expired soft-deleted records daily at 3:15 AM 'purge-soft-deleted-records': { 'task': 'igny8_core.purge_soft_deleted', diff --git a/backend/igny8_core/modules/integration/urls.py b/backend/igny8_core/modules/integration/urls.py index a5395f48..820e12aa 100644 --- a/backend/igny8_core/modules/integration/urls.py +++ b/backend/igny8_core/modules/integration/urls.py @@ -5,7 +5,7 @@ Phase 6: Site Integration & Multi-Destination Publishing from django.urls import path, include from rest_framework.routers import DefaultRouter -from igny8_core.modules.integration.views import IntegrationViewSet +from igny8_core.modules.integration.views import IntegrationViewSet, PublishingSettingsViewSet from igny8_core.modules.integration.webhooks import ( wordpress_status_webhook, wordpress_metadata_webhook, @@ -14,9 +14,19 @@ from igny8_core.modules.integration.webhooks import ( router = DefaultRouter() router.register(r'integrations', IntegrationViewSet, basename='integration') +# Create PublishingSettings ViewSet instance +publishing_settings_viewset = PublishingSettingsViewSet.as_view({ + 'get': 'retrieve', + 'put': 'update', + 'patch': 'partial_update', +}) + urlpatterns = [ path('', include(router.urls)), + # Site-level publishing settings + path('sites//publishing-settings/', publishing_settings_viewset, name='publishing-settings'), + # Webhook endpoints path('webhooks/wordpress/status/', wordpress_status_webhook, name='wordpress-status-webhook'), path('webhooks/wordpress/metadata/', wordpress_metadata_webhook, name='wordpress-metadata-webhook'), diff --git a/backend/igny8_core/modules/integration/views.py b/backend/igny8_core/modules/integration/views.py index 586c6226..bb1afa0d 100644 --- a/backend/igny8_core/modules/integration/views.py +++ b/backend/igny8_core/modules/integration/views.py @@ -838,5 +838,148 @@ class IntegrationViewSet(SiteSectorModelViewSet): }, request=request) +# PublishingSettings ViewSet +from rest_framework import serializers, viewsets +from igny8_core.business.integration.models import PublishingSettings +class PublishingSettingsSerializer(serializers.ModelSerializer): + """Serializer for PublishingSettings model""" + + class Meta: + model = PublishingSettings + fields = [ + 'id', + 'site', + 'auto_approval_enabled', + 'auto_publish_enabled', + 'daily_publish_limit', + 'weekly_publish_limit', + 'monthly_publish_limit', + 'publish_days', + 'publish_time_slots', + 'created_at', + 'updated_at', + ] + read_only_fields = ['id', 'site', 'created_at', 'updated_at'] + + +@extend_schema_view( + retrieve=extend_schema(tags=['Integration']), + update=extend_schema(tags=['Integration']), + partial_update=extend_schema(tags=['Integration']), +) +class PublishingSettingsViewSet(viewsets.ViewSet): + """ + ViewSet for managing site-level publishing settings. + + GET /api/v1/integration/sites/{site_id}/publishing-settings/ + PUT /api/v1/integration/sites/{site_id}/publishing-settings/ + PATCH /api/v1/integration/sites/{site_id}/publishing-settings/ + """ + permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] + throttle_scope = 'integration' + throttle_classes = [DebugScopedRateThrottle] + + def _get_site(self, site_id, request): + """Get site and verify user has access""" + from igny8_core.auth.models import Site + try: + site = Site.objects.get(id=int(site_id)) + # Check if user has access to this site (same account) + if hasattr(request, 'account') and site.account != request.account: + return None + return site + except (Site.DoesNotExist, ValueError, TypeError): + return None + + @extend_schema(tags=['Integration']) + def retrieve(self, request, site_id=None): + """ + Get publishing settings for a site. + Creates default settings if they don't exist. + """ + site = self._get_site(site_id, request) + if not site: + return error_response( + 'Site not found or access denied', + None, + status.HTTP_404_NOT_FOUND, + request + ) + + # Get or create settings with defaults + settings, created = PublishingSettings.get_or_create_for_site(site) + + serializer = PublishingSettingsSerializer(settings) + return success_response( + data=serializer.data, + message='Publishing settings retrieved' + (' (created with defaults)' if created else ''), + request=request + ) + + @extend_schema(tags=['Integration']) + def update(self, request, site_id=None): + """ + Update publishing settings for a site (full update). + """ + site = self._get_site(site_id, request) + if not site: + return error_response( + 'Site not found or access denied', + None, + status.HTTP_404_NOT_FOUND, + request + ) + + # Get or create settings + settings, _ = PublishingSettings.get_or_create_for_site(site) + + serializer = PublishingSettingsSerializer(settings, data=request.data) + if serializer.is_valid(): + serializer.save() + return success_response( + data=serializer.data, + message='Publishing settings updated', + request=request + ) + + return error_response( + 'Validation failed', + serializer.errors, + status.HTTP_400_BAD_REQUEST, + request + ) + + @extend_schema(tags=['Integration']) + def partial_update(self, request, site_id=None): + """ + Partially update publishing settings for a site. + """ + site = self._get_site(site_id, request) + if not site: + return error_response( + 'Site not found or access denied', + None, + status.HTTP_404_NOT_FOUND, + request + ) + + # Get or create settings + settings, _ = PublishingSettings.get_or_create_for_site(site) + + serializer = PublishingSettingsSerializer(settings, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return success_response( + data=serializer.data, + message='Publishing settings updated', + request=request + ) + + return error_response( + 'Validation failed', + serializer.errors, + status.HTTP_400_BAD_REQUEST, + request + ) diff --git a/backend/igny8_core/modules/writer/migrations/0015_add_publishing_scheduler_fields.py b/backend/igny8_core/modules/writer/migrations/0015_add_publishing_scheduler_fields.py new file mode 100644 index 00000000..545ada0f --- /dev/null +++ b/backend/igny8_core/modules/writer/migrations/0015_add_publishing_scheduler_fields.py @@ -0,0 +1,49 @@ +# Generated migration for publishing scheduler fields + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('writer', '0014_add_approved_status'), + ] + + operations = [ + migrations.AddField( + model_name='content', + name='site_status', + field=models.CharField( + choices=[ + ('not_published', 'Not Published'), + ('scheduled', 'Scheduled'), + ('publishing', 'Publishing'), + ('published', 'Published'), + ('failed', 'Failed'), + ], + db_index=True, + default='not_published', + help_text='External site publishing status', + max_length=50, + ), + ), + migrations.AddField( + model_name='content', + name='scheduled_publish_at', + field=models.DateTimeField( + blank=True, + db_index=True, + help_text='Scheduled time for publishing to external site', + null=True, + ), + ), + migrations.AddField( + model_name='content', + name='site_status_updated_at', + field=models.DateTimeField( + blank=True, + help_text='Last time site_status was changed', + null=True, + ), + ), + ] diff --git a/backend/igny8_core/modules/writer/serializers.py b/backend/igny8_core/modules/writer/serializers.py index 6a4853f5..a5e07b5e 100644 --- a/backend/igny8_core/modules/writer/serializers.py +++ b/backend/igny8_core/modules/writer/serializers.py @@ -186,6 +186,9 @@ class ContentSerializer(serializers.ModelSerializer): 'external_url', 'source', 'status', + 'site_status', + 'scheduled_publish_at', + 'site_status_updated_at', 'word_count', 'sector_name', 'site_id', @@ -197,7 +200,7 @@ class ContentSerializer(serializers.ModelSerializer): 'created_at', 'updated_at', ] - read_only_fields = ['id', 'created_at', 'updated_at', 'account_id'] + read_only_fields = ['id', 'created_at', 'updated_at', 'account_id', 'site_status', 'scheduled_publish_at', 'site_status_updated_at'] def validate(self, attrs): """Ensure required fields for Content creation""" diff --git a/backend/igny8_core/tasks/__init__.py b/backend/igny8_core/tasks/__init__.py new file mode 100644 index 00000000..9a566351 --- /dev/null +++ b/backend/igny8_core/tasks/__init__.py @@ -0,0 +1,11 @@ +""" +IGNY8 Celery Tasks + +This module contains all Celery background tasks for the application. +""" + +# Import all task modules to ensure they're registered with Celery +from igny8_core.tasks.plan_limits import * +from igny8_core.tasks.backup import * +from igny8_core.tasks.wordpress_publishing import * +from igny8_core.tasks.publishing_scheduler import * diff --git a/backend/igny8_core/tasks/publishing_scheduler.py b/backend/igny8_core/tasks/publishing_scheduler.py new file mode 100644 index 00000000..04d3880b --- /dev/null +++ b/backend/igny8_core/tasks/publishing_scheduler.py @@ -0,0 +1,361 @@ +""" +IGNY8 Publishing Scheduler Tasks + +Celery tasks for scheduling and processing automated content publishing: +1. schedule_approved_content: Runs hourly - schedules approved content for publishing +2. process_scheduled_publications: Runs every 5 minutes - processes scheduled content +""" +from celery import shared_task +from django.utils import timezone +from datetime import datetime, timedelta +import logging +from typing import Dict, Any + +logger = logging.getLogger(__name__) + + +@shared_task(name='publishing.schedule_approved_content') +def schedule_approved_content() -> Dict[str, Any]: + """ + Hourly task that schedules approved content for publishing based on PublishingSettings. + + For each site with PublishingSettings.auto_publish_enabled: + 1. Gets all content with status='approved' and site_status='not_published' + 2. Calculates next available publish slots based on: + - publish_days (which days are allowed) + - publish_time_slots (which times are allowed) + - daily/weekly/monthly limits + 3. Sets scheduled_publish_at and site_status='scheduled' + + Returns: + Dict with scheduling results per site + """ + from igny8_core.business.content.models import Content + from igny8_core.business.integration.models import PublishingSettings + from igny8_core.auth.models import Site + + results = { + 'sites_processed': 0, + 'content_scheduled': 0, + 'errors': [], + 'details': [] + } + + try: + # Get all sites with auto_publish enabled + sites_with_settings = PublishingSettings.objects.filter(auto_publish_enabled=True) + + for settings in sites_with_settings: + site = settings.site + site_result = { + 'site_id': site.id, + 'site_name': site.name, + 'scheduled_count': 0 + } + + try: + # Get approved content that's not yet scheduled + pending_content = Content.objects.filter( + site=site, + status='approved', + site_status='not_published', + scheduled_publish_at__isnull=True + ).order_by('created_at') + + if not pending_content.exists(): + logger.debug(f"Site {site.id}: No pending content to schedule") + results['details'].append(site_result) + results['sites_processed'] += 1 + continue + + # Calculate available slots + available_slots = _calculate_available_slots(settings, site) + + # Assign slots to content + for i, content in enumerate(pending_content): + if i >= len(available_slots): + logger.info(f"Site {site.id}: No more slots available (limit reached)") + break + + scheduled_time = available_slots[i] + content.scheduled_publish_at = scheduled_time + content.site_status = 'scheduled' + content.site_status_updated_at = timezone.now() + content.save(update_fields=['scheduled_publish_at', 'site_status', 'site_status_updated_at']) + + site_result['scheduled_count'] += 1 + results['content_scheduled'] += 1 + + logger.info(f"Scheduled content {content.id} for {scheduled_time}") + + results['details'].append(site_result) + results['sites_processed'] += 1 + + except Exception as e: + error_msg = f"Error processing site {site.id}: {str(e)}" + logger.error(error_msg) + results['errors'].append(error_msg) + + logger.info(f"Publishing scheduler completed: {results['content_scheduled']} content items scheduled across {results['sites_processed']} sites") + return results + + except Exception as e: + error_msg = f"Fatal error in schedule_approved_content: {str(e)}" + logger.error(error_msg) + results['errors'].append(error_msg) + return results + + +def _calculate_available_slots(settings: 'PublishingSettings', site: 'Site') -> list: + """ + Calculate available publishing time slots based on settings and limits. + + Args: + settings: PublishingSettings instance + site: Site instance + + Returns: + List of datetime objects representing available slots + """ + from igny8_core.business.content.models import Content + + now = timezone.now() + slots = [] + + # Get configured days and times + publish_days = settings.publish_days or ['mon', 'tue', 'wed', 'thu', 'fri'] + publish_times = settings.publish_time_slots or ['09:00', '14:00', '18:00'] + + # Day name mapping + day_map = { + 'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, + 'fri': 4, 'sat': 5, 'sun': 6 + } + allowed_days = [day_map.get(d.lower(), -1) for d in publish_days] + allowed_days = [d for d in allowed_days if d >= 0] + + # Parse time slots + time_slots = [] + for time_str in publish_times: + try: + hour, minute = map(int, time_str.split(':')) + time_slots.append((hour, minute)) + except (ValueError, AttributeError): + continue + + if not time_slots: + time_slots = [(9, 0), (14, 0), (18, 0)] + + # Calculate limits + daily_limit = settings.daily_publish_limit + weekly_limit = settings.weekly_publish_limit + monthly_limit = settings.monthly_publish_limit + + # Count existing scheduled/published content + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_start = today_start - timedelta(days=now.weekday()) + month_start = today_start.replace(day=1) + + daily_count = Content.objects.filter( + site=site, + site_status__in=['scheduled', 'publishing', 'published'], + scheduled_publish_at__gte=today_start + ).count() + + weekly_count = Content.objects.filter( + site=site, + site_status__in=['scheduled', 'publishing', 'published'], + scheduled_publish_at__gte=week_start + ).count() + + monthly_count = Content.objects.filter( + site=site, + site_status__in=['scheduled', 'publishing', 'published'], + scheduled_publish_at__gte=month_start + ).count() + + # Generate slots for next 30 days + current_date = now.date() + slots_per_day = {} # Track slots used per day + + for day_offset in range(30): + check_date = current_date + timedelta(days=day_offset) + + # Check if day is allowed + if check_date.weekday() not in allowed_days: + continue + + for hour, minute in time_slots: + slot_time = timezone.make_aware( + datetime.combine(check_date, datetime.min.time().replace(hour=hour, minute=minute)) + ) + + # Skip if in the past + if slot_time <= now: + continue + + # Check daily limit + day_key = check_date.isoformat() + slots_this_day = slots_per_day.get(day_key, 0) + if daily_limit and (daily_count + slots_this_day) >= daily_limit: + continue + + # Check weekly limit + slot_week_start = slot_time - timedelta(days=slot_time.weekday()) + if slot_week_start.date() == week_start.date(): + scheduled_in_week = weekly_count + len([s for s in slots if s >= week_start]) + if weekly_limit and scheduled_in_week >= weekly_limit: + continue + + # Check monthly limit + if slot_time.month == now.month and slot_time.year == now.year: + scheduled_in_month = monthly_count + len([s for s in slots if s.month == now.month]) + if monthly_limit and scheduled_in_month >= monthly_limit: + continue + + slots.append(slot_time) + slots_per_day[day_key] = slots_per_day.get(day_key, 0) + 1 + + # Limit total slots to prevent memory issues + if len(slots) >= 100: + return slots + + return slots + + +@shared_task(name='publishing.process_scheduled_publications') +def process_scheduled_publications() -> Dict[str, Any]: + """ + Every 5 minutes: Process content scheduled for publishing. + + Finds all content where: + - site_status = 'scheduled' + - scheduled_publish_at <= now + + For each, triggers the WordPress publishing task. + + Returns: + Dict with processing results + """ + from igny8_core.business.content.models import Content + from igny8_core.business.integration.models import SiteIntegration + from igny8_core.tasks.wordpress_publishing import publish_content_to_wordpress + + results = { + 'processed': 0, + 'published': 0, + 'failed': 0, + 'errors': [] + } + + now = timezone.now() + + try: + # Get all scheduled content that's due + due_content = Content.objects.filter( + site_status='scheduled', + scheduled_publish_at__lte=now + ).select_related('site', 'task') + + for content in due_content: + results['processed'] += 1 + + try: + # Update status to publishing + content.site_status = 'publishing' + content.site_status_updated_at = timezone.now() + content.save(update_fields=['site_status', 'site_status_updated_at']) + + # Get site integration + site_integration = SiteIntegration.objects.filter( + site=content.site, + platform='wordpress', + is_active=True + ).first() + + if not site_integration: + error_msg = f"No active WordPress integration for site {content.site_id}" + logger.error(error_msg) + content.site_status = 'failed' + content.site_status_updated_at = timezone.now() + content.save(update_fields=['site_status', 'site_status_updated_at']) + results['failed'] += 1 + results['errors'].append(error_msg) + continue + + # Queue the WordPress publishing task + task_id = content.task_id if hasattr(content, 'task') and content.task else None + publish_content_to_wordpress.delay( + content_id=content.id, + site_integration_id=site_integration.id, + task_id=task_id + ) + + logger.info(f"Queued content {content.id} for WordPress publishing") + results['published'] += 1 + + except Exception as e: + error_msg = f"Error processing content {content.id}: {str(e)}" + logger.error(error_msg) + content.site_status = 'failed' + content.site_status_updated_at = timezone.now() + content.save(update_fields=['site_status', 'site_status_updated_at']) + results['failed'] += 1 + results['errors'].append(error_msg) + + logger.info(f"Processing completed: {results['published']}/{results['processed']} published successfully") + return results + + except Exception as e: + error_msg = f"Fatal error in process_scheduled_publications: {str(e)}" + logger.error(error_msg) + results['errors'].append(error_msg) + return results + + +@shared_task(name='publishing.update_content_site_status') +def update_content_site_status(content_id: int, new_status: str, external_id: str = None, external_url: str = None) -> Dict[str, Any]: + """ + Update content site_status after WordPress publishing completes. + Called by the WordPress publishing task upon success or failure. + + Args: + content_id: Content ID to update + new_status: New site_status ('published' or 'failed') + external_id: WordPress post ID (on success) + external_url: WordPress post URL (on success) + + Returns: + Dict with update result + """ + from igny8_core.business.content.models import Content + + try: + content = Content.objects.get(id=content_id) + content.site_status = new_status + content.site_status_updated_at = timezone.now() + + if external_id: + content.external_id = external_id + if external_url: + content.external_url = external_url + + update_fields = ['site_status', 'site_status_updated_at'] + if external_id: + update_fields.append('external_id') + if external_url: + update_fields.append('external_url') + + content.save(update_fields=update_fields) + + logger.info(f"Updated content {content_id} site_status to {new_status}") + return {'success': True, 'content_id': content_id, 'new_status': new_status} + + except Content.DoesNotExist: + error_msg = f"Content {content_id} not found" + logger.error(error_msg) + return {'success': False, 'error': error_msg} + except Exception as e: + error_msg = f"Error updating content {content_id}: {str(e)}" + logger.error(error_msg) + return {'success': False, 'error': error_msg} diff --git a/backend/igny8_core/tasks/wordpress_publishing.py b/backend/igny8_core/tasks/wordpress_publishing.py index d295acca..aa802050 100644 --- a/backend/igny8_core/tasks/wordpress_publishing.py +++ b/backend/igny8_core/tasks/wordpress_publishing.py @@ -354,12 +354,14 @@ def publish_content_to_wordpress(self, content_id: int, site_integration_id: int content.external_id = str(wp_data.get('post_id')) content.external_url = wp_data.get('post_url') content.status = 'published' + content.site_status = 'published' + content.site_status_updated_at = timezone.now() if not hasattr(content, 'metadata') or content.metadata is None: content.metadata = {} content.metadata['wordpress_status'] = wp_status - content.save(update_fields=['external_id', 'external_url', 'status', 'metadata', 'updated_at']) + content.save(update_fields=['external_id', 'external_url', 'status', 'site_status', 'site_status_updated_at', 'metadata', 'updated_at']) publish_logger.info(f" {log_prefix} ✅ Content model updated:") publish_logger.info(f" {log_prefix} External ID: {content.external_id}") @@ -425,12 +427,14 @@ def publish_content_to_wordpress(self, content_id: int, site_integration_id: int content.external_id = str(wp_data.get('post_id')) content.external_url = wp_data.get('post_url') content.status = 'published' + content.site_status = 'published' + content.site_status_updated_at = timezone.now() if not hasattr(content, 'metadata') or content.metadata is None: content.metadata = {} content.metadata['wordpress_status'] = wp_status - content.save(update_fields=['external_id', 'external_url', 'status', 'metadata', 'updated_at']) + content.save(update_fields=['external_id', 'external_url', 'status', 'site_status', 'site_status_updated_at', 'metadata', 'updated_at']) # Log sync event duration_ms = int((time.time() - start_time) * 1000) @@ -525,6 +529,16 @@ def publish_content_to_wordpress(self, content_id: int, site_integration_id: int publish_logger.error(f" {prefix} Duration: {duration_ms}ms") publish_logger.error("="*80, exc_info=True) + # Update site_status to failed if content was loaded + try: + if content and content.id: + content.site_status = 'failed' + content.site_status_updated_at = timezone.now() + content.save(update_fields=['site_status', 'site_status_updated_at']) + publish_logger.info(f"Updated content {content.id} site_status to 'failed'") + except Exception as update_error: + publish_logger.error(f"Failed to update site_status: {str(update_error)}") + # Try to log sync event try: from igny8_core.business.integration.models import SyncEvent diff --git a/frontend/src/components/BulkWordPressPublish/BulkWordPressPublish.tsx b/frontend/src/components/BulkWordPressPublish/BulkWordPressPublish.tsx index 587cd476..cb199d88 100644 --- a/frontend/src/components/BulkWordPressPublish/BulkWordPressPublish.tsx +++ b/frontend/src/components/BulkWordPressPublish/BulkWordPressPublish.tsx @@ -200,7 +200,7 @@ export const BulkWordPressPublish: React.FC = ({ onClick={handleOpen} disabled={selectedContentIds.length === 0} > - Bulk Publish to WordPress ({selectedContentIds.length}) + Bulk Publish to Site ({selectedContentIds.length}) = ({ fullWidth > - Bulk Publish to WordPress + Bulk Publish to Site {!publishing && !result && ( <> - You are about to publish {selectedContentIds.length} content items to WordPress: + You are about to publish {selectedContentIds.length} content items to your site: diff --git a/frontend/src/components/WordPressPublish/BulkWordPressPublish.tsx b/frontend/src/components/WordPressPublish/BulkWordPressPublish.tsx index f6e790d9..939a2778 100644 --- a/frontend/src/components/WordPressPublish/BulkWordPressPublish.tsx +++ b/frontend/src/components/WordPressPublish/BulkWordPressPublish.tsx @@ -138,14 +138,14 @@ export const BulkWordPressPublish: React.FC = ({ disableEscapeKeyDown={publishing} > - Bulk Publish to WordPress + Bulk Publish to Site {!publishing && results.length === 0 && ( <> - Ready to publish {readyToPublish.length} content items to WordPress: + Ready to publish {readyToPublish.length} content items to your site: diff --git a/frontend/src/components/WordPressPublish/ContentActionsMenu.tsx b/frontend/src/components/WordPressPublish/ContentActionsMenu.tsx index 3a87de06..d4971523 100644 --- a/frontend/src/components/WordPressPublish/ContentActionsMenu.tsx +++ b/frontend/src/components/WordPressPublish/ContentActionsMenu.tsx @@ -99,7 +99,7 @@ export const ContentActionsMenu: React.FC = ({ - Publish to WordPress + Publish to Site diff --git a/frontend/src/components/WordPressPublish/WordPressPublish.tsx b/frontend/src/components/WordPressPublish/WordPressPublish.tsx index d020db0f..91745a8e 100644 --- a/frontend/src/components/WordPressPublish/WordPressPublish.tsx +++ b/frontend/src/components/WordPressPublish/WordPressPublish.tsx @@ -202,7 +202,7 @@ export const WordPressPublish: React.FC = ({ if (!shouldShowPublishButton) { return ( - + ); @@ -323,14 +323,14 @@ export const WordPressPublish: React.FC = ({ maxWidth="sm" fullWidth > - Publish to WordPress + Publish to Site - Are you sure you want to publish "{contentTitle}" to WordPress? + Are you sure you want to publish "{contentTitle}" to your site? - This will create a new post on your connected WordPress site with all content, + This will create a new post on your connected site with all content, images, categories, and SEO metadata. @@ -348,7 +348,7 @@ export const WordPressPublish: React.FC = ({ {wpStatus?.wordpress_sync_status === 'success' && ( - This content is already published to WordPress. + This content is already published to your site. You can force republish to update the existing post. )} diff --git a/frontend/src/config/pages/review.config.tsx b/frontend/src/config/pages/review.config.tsx index 674ebd55..fb47dd62 100644 --- a/frontend/src/config/pages/review.config.tsx +++ b/frontend/src/config/pages/review.config.tsx @@ -202,7 +202,7 @@ export function createReviewPageConfig(params: { {label} {row.external_id && ( - + )} ); diff --git a/frontend/src/config/pages/table-actions.config.tsx b/frontend/src/config/pages/table-actions.config.tsx index 1095ac38..4697bf5e 100644 --- a/frontend/src/config/pages/table-actions.config.tsx +++ b/frontend/src/config/pages/table-actions.config.tsx @@ -251,7 +251,7 @@ const tableActionsConfigs: Record = { { key: 'view_on_wordpress', - label: 'View on WordPress', + label: 'View on Site', icon: , variant: 'secondary', shouldShow: (row: any) => !!row.external_id, // Only show if published @@ -334,14 +334,14 @@ const tableActionsConfigs: Record = { }, { key: 'publish_wordpress', - label: 'Publish to WordPress', + label: 'Publish to Site', icon: , variant: 'success', shouldShow: (row: any) => !row.external_id, // Only show if not published }, { key: 'view_on_wordpress', - label: 'View on WordPress', + label: 'View on Site', icon: , variant: 'secondary', shouldShow: (row: any) => !!row.external_id, // Only show if published @@ -350,7 +350,7 @@ const tableActionsConfigs: Record = { bulkActions: [ { key: 'bulk_publish_wordpress', - label: 'Publish to WordPress', + label: 'Publish to Site', icon: , variant: 'success', }, @@ -389,7 +389,7 @@ const tableActionsConfigs: Record = { }, { key: 'publish_wordpress', - label: 'Publish to WordPress', + label: 'Publish to Site', icon: , variant: 'primary', }, @@ -403,7 +403,7 @@ const tableActionsConfigs: Record = { }, { key: 'bulk_publish_wordpress', - label: 'Publish to WordPress', + label: 'Publish to Site', icon: , variant: 'primary', }, diff --git a/frontend/src/pages/Sites/Settings.tsx b/frontend/src/pages/Sites/Settings.tsx index 7227b8a5..f7fa8ee6 100644 --- a/frontend/src/pages/Sites/Settings.tsx +++ b/frontend/src/pages/Sites/Settings.tsx @@ -46,11 +46,16 @@ export default function SiteSettings() { const siteSelectorRef = useRef(null); // Check for tab parameter in URL - const initialTab = (searchParams.get('tab') as 'general' | 'integrations' | 'content-types') || 'general'; - const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'content-types'>(initialTab); + const initialTab = (searchParams.get('tab') as 'general' | 'integrations' | 'publishing' | 'content-types') || 'general'; + const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'publishing' | 'content-types'>(initialTab); const [contentTypes, setContentTypes] = useState(null); const [contentTypesLoading, setContentTypesLoading] = useState(false); + // Publishing settings state + const [publishingSettings, setPublishingSettings] = useState(null); + const [publishingSettingsLoading, setPublishingSettingsLoading] = useState(false); + const [publishingSettingsSaving, setPublishingSettingsSaving] = useState(false); + // Sectors selection state const [industries, setIndustries] = useState([]); const [selectedIndustry, setSelectedIndustry] = useState(''); @@ -102,7 +107,7 @@ export default function SiteSettings() { useEffect(() => { // Update tab if URL parameter changes const tab = searchParams.get('tab'); - if (tab && ['general', 'integrations', 'content-types'].includes(tab)) { + if (tab && ['general', 'integrations', 'publishing', 'content-types'].includes(tab)) { setActiveTab(tab as typeof activeTab); } }, [searchParams]); @@ -113,6 +118,12 @@ export default function SiteSettings() { } }, [activeTab, wordPressIntegration]); + useEffect(() => { + if (activeTab === 'publishing' && siteId && !publishingSettings) { + loadPublishingSettings(); + } + }, [activeTab, siteId]); + // Load sites for selector useEffect(() => { loadSites(); @@ -197,6 +208,47 @@ export default function SiteSettings() { } }; + const loadPublishingSettings = async () => { + if (!siteId) return; + try { + setPublishingSettingsLoading(true); + const response = await fetchAPI(`/v1/integration/sites/${siteId}/publishing-settings/`); + setPublishingSettings(response.data || response); + } catch (error: any) { + console.error('Failed to load publishing settings:', error); + // Set defaults if endpoint fails + setPublishingSettings({ + auto_approval_enabled: true, + auto_publish_enabled: true, + daily_publish_limit: 3, + weekly_publish_limit: 15, + monthly_publish_limit: 50, + publish_days: ['mon', 'tue', 'wed', 'thu', 'fri'], + publish_time_slots: ['09:00', '14:00', '18:00'], + }); + } finally { + setPublishingSettingsLoading(false); + } + }; + + const savePublishingSettings = async (newSettings: any) => { + if (!siteId) return; + try { + setPublishingSettingsSaving(true); + const response = await fetchAPI(`/v1/integration/sites/${siteId}/publishing-settings/`, { + method: 'PATCH', + body: JSON.stringify(newSettings), + }); + setPublishingSettings(response.data || response); + toast.success('Publishing settings saved successfully'); + } catch (error: any) { + console.error('Failed to save publishing settings:', error); + toast.error('Failed to save publishing settings'); + } finally { + setPublishingSettingsSaving(false); + } + }; + const loadIndustries = async () => { try { const response = await fetchIndustries(); @@ -583,6 +635,21 @@ export default function SiteSettings() { Integrations + {(wordPressIntegration || site?.wp_url || site?.wp_api_key || site?.hosting_type === 'wordpress') && ( + ))} + + + + {/* Publishing Time Slots Section */} +
+

Publishing Time Slots

+

+ Set preferred times for publishing content (in your local timezone) +

+
+ {(publishingSettings.publish_time_slots || ['09:00', '14:00', '18:00']).map((time: string, index: number) => ( +
+ { + const newSlots = [...(publishingSettings.publish_time_slots || [])]; + newSlots[index] = e.target.value; + setPublishingSettings({ ...publishingSettings, publish_time_slots: newSlots }); + }} + onBlur={() => savePublishingSettings({ publish_time_slots: publishingSettings.publish_time_slots })} + className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-brand-500 focus:border-brand-500" + /> + {(publishingSettings.publish_time_slots || []).length > 1 && ( + + )} +
+ ))} + +
+
+ + {/* Info Box */} +
+
+ + + +
+

How Publishing Works

+
    +
  • Content moves from DraftReviewApprovedPublished
  • +
  • Auto-approval moves content from Review to Approved automatically
  • +
  • Auto-publish sends Approved content to your WordPress site
  • +
  • You can always manually publish content using the "Publish to Site" button
  • +
+
+
+
+ + ) : ( +
+

Failed to load publishing settings. Please try again.

+ +
+ )} + + + )} + {/* Content Types Tab */} {activeTab === 'content-types' && ( diff --git a/frontend/src/pages/Writer/Dashboard.tsx b/frontend/src/pages/Writer/Dashboard.tsx index 315e8d2a..f3640e0e 100644 --- a/frontend/src/pages/Writer/Dashboard.tsx +++ b/frontend/src/pages/Writer/Dashboard.tsx @@ -43,6 +43,8 @@ interface WriterStats { total: number; drafts: number; published: number; + publishedToSite: number; + scheduledForPublish: number; totalWordCount: number; avgWordCount: number; byContentType: Record; @@ -109,12 +111,17 @@ export default function WriterDashboard() { const content = contentRes.results || []; let drafts = 0; let published = 0; + let publishedToSite = 0; + let scheduledForPublish = 0; let contentTotalWordCount = 0; const contentByType: Record = {}; content.forEach(c => { if (c.status === 'draft') drafts++; else if (c.status === 'published') published++; + // Count site_status for external publishing metrics + if (c.site_status === 'published') publishedToSite++; + else if (c.site_status === 'scheduled') scheduledForPublish++; if (c.word_count) contentTotalWordCount += c.word_count; }); @@ -162,6 +169,8 @@ export default function WriterDashboard() { total: content.length, drafts, published, + publishedToSite, + scheduledForPublish, totalWordCount: contentTotalWordCount, avgWordCount: contentAvgWordCount, byContentType: contentByType @@ -237,13 +246,13 @@ export default function WriterDashboard() { metric: `${stats?.images.pending || 0} pending`, }, { - title: "Published", - description: "Published content and posts", + title: "Published to Site", + description: "Content published to external site", icon: PaperPlaneIcon, color: "from-[var(--color-purple)] to-[var(--color-purple-dark)]", path: "/writer/published", - count: stats?.content.published || 0, - metric: "View all published", + count: stats?.content.publishedToSite || 0, + metric: stats?.content.scheduledForPublish ? `${stats.content.scheduledForPublish} scheduled` : "None scheduled", }, { title: "Taxonomies", @@ -269,7 +278,7 @@ export default function WriterDashboard() { { id: 1, type: "Content Published", - description: `${stats?.content.published || 0} pieces published to WordPress`, + description: `${stats?.content.published || 0} pieces published to site`, timestamp: new Date(Date.now() - 30 * 60 * 1000), icon: PaperPlaneIcon, color: "text-success-600", @@ -723,7 +732,7 @@ export default function WriterDashboard() {

Publish Content

-

Publish to WordPress

+

Publish to Site

diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 03c76f0c..e965c01c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -2240,6 +2240,10 @@ export interface Content { external_id?: string | null; external_url?: string | null; wordpress_status?: 'publish' | 'draft' | 'pending' | 'future' | 'private' | 'trash' | null; + // Site publishing status + site_status?: 'not_published' | 'scheduled' | 'publishing' | 'published' | 'failed'; + scheduled_publish_at?: string | null; + site_status_updated_at?: string | null; // Timestamps created_at: string; updated_at: string;