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.
This commit is contained in:
@@ -1476,11 +1476,36 @@ class AutomationService:
|
|||||||
|
|
||||||
This stage automatically approves content in 'review' status and
|
This stage automatically approves content in 'review' status and
|
||||||
marks it as 'approved' (ready for publishing to WordPress).
|
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_number = 7
|
||||||
stage_name = "Review → Approved"
|
stage_name = "Review → Approved"
|
||||||
start_time = time.time()
|
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
|
# Query content ready for review
|
||||||
ready_for_review = Content.objects.filter(
|
ready_for_review = Content.objects.filter(
|
||||||
site=self.site,
|
site=self.site,
|
||||||
@@ -1602,13 +1627,55 @@ class AutomationService:
|
|||||||
stage_number, approved_count, time_elapsed, 0
|
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 = {
|
self.run.stage_7_result = {
|
||||||
'ready_for_review': total_count,
|
'ready_for_review': total_count,
|
||||||
'review_total': total_count,
|
'review_total': total_count,
|
||||||
'approved_count': approved_count,
|
'approved_count': approved_count,
|
||||||
'content_ids': content_ids,
|
'content_ids': content_ids,
|
||||||
'time_elapsed': time_elapsed,
|
'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.status = 'completed'
|
||||||
self.run.completed_at = datetime.now()
|
self.run.completed_at = datetime.now()
|
||||||
@@ -1617,7 +1684,8 @@ class AutomationService:
|
|||||||
# Release lock
|
# Release lock
|
||||||
cache.delete(f'automation_lock_{self.site.id}')
|
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):
|
def pause_automation(self):
|
||||||
"""Pause current automation run"""
|
"""Pause current automation run"""
|
||||||
|
|||||||
@@ -282,6 +282,33 @@ class Content(SoftDeletableModel, SiteSectorBaseModel):
|
|||||||
help_text="Content status"
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -244,3 +244,100 @@ class SyncEvent(AccountBaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.get_event_type_display()} - {self.description[:50]}"
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ app.config_from_object('django.conf:settings', namespace='CELERY')
|
|||||||
# Load task modules from all registered Django apps.
|
# Load task modules from all registered Django apps.
|
||||||
app.autodiscover_tasks()
|
app.autodiscover_tasks()
|
||||||
|
|
||||||
|
# Explicitly import tasks from igny8_core/tasks directory
|
||||||
|
app.autodiscover_tasks(['igny8_core.tasks'])
|
||||||
|
|
||||||
# Celery Beat schedule for periodic tasks
|
# Celery Beat schedule for periodic tasks
|
||||||
app.conf.beat_schedule = {
|
app.conf.beat_schedule = {
|
||||||
'replenish-monthly-credits': {
|
'replenish-monthly-credits': {
|
||||||
@@ -39,6 +42,15 @@ app.conf.beat_schedule = {
|
|||||||
'task': 'automation.check_scheduled_automations',
|
'task': 'automation.check_scheduled_automations',
|
||||||
'schedule': crontab(minute=0), # Every hour at :00
|
'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
|
# Maintenance: purge expired soft-deleted records daily at 3:15 AM
|
||||||
'purge-soft-deleted-records': {
|
'purge-soft-deleted-records': {
|
||||||
'task': 'igny8_core.purge_soft_deleted',
|
'task': 'igny8_core.purge_soft_deleted',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Phase 6: Site Integration & Multi-Destination Publishing
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
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 (
|
from igny8_core.modules.integration.webhooks import (
|
||||||
wordpress_status_webhook,
|
wordpress_status_webhook,
|
||||||
wordpress_metadata_webhook,
|
wordpress_metadata_webhook,
|
||||||
@@ -14,9 +14,19 @@ from igny8_core.modules.integration.webhooks import (
|
|||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'integrations', IntegrationViewSet, basename='integration')
|
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 = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
|
|
||||||
|
# Site-level publishing settings
|
||||||
|
path('sites/<int:site_id>/publishing-settings/', publishing_settings_viewset, name='publishing-settings'),
|
||||||
|
|
||||||
# Webhook endpoints
|
# Webhook endpoints
|
||||||
path('webhooks/wordpress/status/', wordpress_status_webhook, name='wordpress-status-webhook'),
|
path('webhooks/wordpress/status/', wordpress_status_webhook, name='wordpress-status-webhook'),
|
||||||
path('webhooks/wordpress/metadata/', wordpress_metadata_webhook, name='wordpress-metadata-webhook'),
|
path('webhooks/wordpress/metadata/', wordpress_metadata_webhook, name='wordpress-metadata-webhook'),
|
||||||
|
|||||||
@@ -838,5 +838,148 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
|||||||
}, request=request)
|
}, 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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -186,6 +186,9 @@ class ContentSerializer(serializers.ModelSerializer):
|
|||||||
'external_url',
|
'external_url',
|
||||||
'source',
|
'source',
|
||||||
'status',
|
'status',
|
||||||
|
'site_status',
|
||||||
|
'scheduled_publish_at',
|
||||||
|
'site_status_updated_at',
|
||||||
'word_count',
|
'word_count',
|
||||||
'sector_name',
|
'sector_name',
|
||||||
'site_id',
|
'site_id',
|
||||||
@@ -197,7 +200,7 @@ class ContentSerializer(serializers.ModelSerializer):
|
|||||||
'created_at',
|
'created_at',
|
||||||
'updated_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):
|
def validate(self, attrs):
|
||||||
"""Ensure required fields for Content creation"""
|
"""Ensure required fields for Content creation"""
|
||||||
|
|||||||
11
backend/igny8_core/tasks/__init__.py
Normal file
11
backend/igny8_core/tasks/__init__.py
Normal file
@@ -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 *
|
||||||
361
backend/igny8_core/tasks/publishing_scheduler.py
Normal file
361
backend/igny8_core/tasks/publishing_scheduler.py
Normal file
@@ -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}
|
||||||
@@ -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_id = str(wp_data.get('post_id'))
|
||||||
content.external_url = wp_data.get('post_url')
|
content.external_url = wp_data.get('post_url')
|
||||||
content.status = 'published'
|
content.status = 'published'
|
||||||
|
content.site_status = 'published'
|
||||||
|
content.site_status_updated_at = timezone.now()
|
||||||
|
|
||||||
if not hasattr(content, 'metadata') or content.metadata is None:
|
if not hasattr(content, 'metadata') or content.metadata is None:
|
||||||
content.metadata = {}
|
content.metadata = {}
|
||||||
content.metadata['wordpress_status'] = wp_status
|
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} ✅ Content model updated:")
|
||||||
publish_logger.info(f" {log_prefix} External ID: {content.external_id}")
|
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_id = str(wp_data.get('post_id'))
|
||||||
content.external_url = wp_data.get('post_url')
|
content.external_url = wp_data.get('post_url')
|
||||||
content.status = 'published'
|
content.status = 'published'
|
||||||
|
content.site_status = 'published'
|
||||||
|
content.site_status_updated_at = timezone.now()
|
||||||
|
|
||||||
if not hasattr(content, 'metadata') or content.metadata is None:
|
if not hasattr(content, 'metadata') or content.metadata is None:
|
||||||
content.metadata = {}
|
content.metadata = {}
|
||||||
content.metadata['wordpress_status'] = wp_status
|
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
|
# Log sync event
|
||||||
duration_ms = int((time.time() - start_time) * 1000)
|
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(f" {prefix} Duration: {duration_ms}ms")
|
||||||
publish_logger.error("="*80, exc_info=True)
|
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 to log sync event
|
||||||
try:
|
try:
|
||||||
from igny8_core.business.integration.models import SyncEvent
|
from igny8_core.business.integration.models import SyncEvent
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export const BulkWordPressPublish: React.FC<BulkWordPressPublishProps> = ({
|
|||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
disabled={selectedContentIds.length === 0}
|
disabled={selectedContentIds.length === 0}
|
||||||
>
|
>
|
||||||
Bulk Publish to WordPress ({selectedContentIds.length})
|
Bulk Publish to Site ({selectedContentIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -210,14 +210,14 @@ export const BulkWordPressPublish: React.FC<BulkWordPressPublishProps> = ({
|
|||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Bulk Publish to WordPress
|
Bulk Publish to Site
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{!publishing && !result && (
|
{!publishing && !result && (
|
||||||
<>
|
<>
|
||||||
<Typography variant="body1" gutterBottom>
|
<Typography variant="body1" gutterBottom>
|
||||||
You are about to publish {selectedContentIds.length} content items to WordPress:
|
You are about to publish {selectedContentIds.length} content items to your site:
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<List dense sx={{ maxHeight: 300, overflow: 'auto', mt: 2 }}>
|
<List dense sx={{ maxHeight: 300, overflow: 'auto', mt: 2 }}>
|
||||||
|
|||||||
@@ -138,14 +138,14 @@ export const BulkWordPressPublish: React.FC<BulkWordPressPublishProps> = ({
|
|||||||
disableEscapeKeyDown={publishing}
|
disableEscapeKeyDown={publishing}
|
||||||
>
|
>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Bulk Publish to WordPress
|
Bulk Publish to Site
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{!publishing && results.length === 0 && (
|
{!publishing && results.length === 0 && (
|
||||||
<>
|
<>
|
||||||
<Typography variant="body1" gutterBottom>
|
<Typography variant="body1" gutterBottom>
|
||||||
Ready to publish <strong>{readyToPublish.length}</strong> content items to WordPress:
|
Ready to publish <strong>{readyToPublish.length}</strong> content items to your site:
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Alert severity="info" sx={{ mt: 2, mb: 2 }}>
|
<Alert severity="info" sx={{ mt: 2, mb: 2 }}>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const ContentActionsMenu: React.FC<ContentActionsMenuProps> = ({
|
|||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<PublishIcon fontSize="small" />
|
<PublishIcon fontSize="small" />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText>Publish to WordPress</ListItemText>
|
<ListItemText>Publish to Site</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export const WordPressPublish: React.FC<WordPressPublishProps> = ({
|
|||||||
|
|
||||||
if (!shouldShowPublishButton) {
|
if (!shouldShowPublishButton) {
|
||||||
return (
|
return (
|
||||||
<Tooltip title={`Images must be generated before publishing to WordPress`}>
|
<Tooltip title={`Images must be generated before publishing to site`}>
|
||||||
<Box display="flex" alignItems="center" gap={1}>
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -229,7 +229,7 @@ export const WordPressPublish: React.FC<WordPressPublishProps> = ({
|
|||||||
const renderButton = () => {
|
const renderButton = () => {
|
||||||
if (size === 'small') {
|
if (size === 'small') {
|
||||||
return (
|
return (
|
||||||
<Tooltip title={`WordPress: ${statusInfo.label}`}>
|
<Tooltip title={`Site: ${statusInfo.label}`}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -267,9 +267,9 @@ export const WordPressPublish: React.FC<WordPressPublishProps> = ({
|
|||||||
disabled={loading || statusInfo.action === 'wait'}
|
disabled={loading || statusInfo.action === 'wait'}
|
||||||
size={size}
|
size={size}
|
||||||
>
|
>
|
||||||
{statusInfo.action === 'publish' && 'Publish to WordPress'}
|
{statusInfo.action === 'publish' && 'Publish to Site'}
|
||||||
{statusInfo.action === 'retry' && 'Retry'}
|
{statusInfo.action === 'retry' && 'Retry'}
|
||||||
{statusInfo.action === 'view' && 'View on WordPress'}
|
{statusInfo.action === 'view' && 'View on Site'}
|
||||||
{statusInfo.action === 'wait' && statusInfo.label}
|
{statusInfo.action === 'wait' && statusInfo.label}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -323,14 +323,14 @@ export const WordPressPublish: React.FC<WordPressPublishProps> = ({
|
|||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
<DialogTitle>Publish to WordPress</DialogTitle>
|
<DialogTitle>Publish to Site</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Typography variant="body1" gutterBottom>
|
<Typography variant="body1" gutterBottom>
|
||||||
Are you sure you want to publish "<strong>{contentTitle}</strong>" to WordPress?
|
Are you sure you want to publish "<strong>{contentTitle}</strong>" to your site?
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 2 }}>
|
<Typography variant="body2" color="textSecondary" sx={{ mt: 2 }}>
|
||||||
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.
|
images, categories, and SEO metadata.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
@@ -348,7 +348,7 @@ export const WordPressPublish: React.FC<WordPressPublishProps> = ({
|
|||||||
|
|
||||||
{wpStatus?.wordpress_sync_status === 'success' && (
|
{wpStatus?.wordpress_sync_status === 'success' && (
|
||||||
<Alert severity="info" sx={{ mt: 2 }}>
|
<Alert severity="info" sx={{ mt: 2 }}>
|
||||||
This content is already published to WordPress.
|
This content is already published to your site.
|
||||||
You can force republish to update the existing post.
|
You can force republish to update the existing post.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export function createReviewPageConfig(params: {
|
|||||||
<span className="text-[11px] font-normal">{label}</span>
|
<span className="text-[11px] font-normal">{label}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{row.external_id && (
|
{row.external_id && (
|
||||||
<CheckCircleIcon className="w-3.5 h-3.5 text-success-500" title="Published to WordPress" />
|
<CheckCircleIcon className="w-3.5 h-3.5 text-success-500" title="Published to Site" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
|
|
||||||
{
|
{
|
||||||
key: 'view_on_wordpress',
|
key: 'view_on_wordpress',
|
||||||
label: 'View on WordPress',
|
label: 'View on Site',
|
||||||
icon: <CheckCircleIcon className="w-5 h-5 text-brand-500" />,
|
icon: <CheckCircleIcon className="w-5 h-5 text-brand-500" />,
|
||||||
variant: 'secondary',
|
variant: 'secondary',
|
||||||
shouldShow: (row: any) => !!row.external_id, // Only show if published
|
shouldShow: (row: any) => !!row.external_id, // Only show if published
|
||||||
@@ -334,14 +334,14 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'publish_wordpress',
|
key: 'publish_wordpress',
|
||||||
label: 'Publish to WordPress',
|
label: 'Publish to Site',
|
||||||
icon: <ArrowRightIcon className="w-5 h-5" />,
|
icon: <ArrowRightIcon className="w-5 h-5" />,
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
shouldShow: (row: any) => !row.external_id, // Only show if not published
|
shouldShow: (row: any) => !row.external_id, // Only show if not published
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'view_on_wordpress',
|
key: 'view_on_wordpress',
|
||||||
label: 'View on WordPress',
|
label: 'View on Site',
|
||||||
icon: <CheckCircleIcon className="w-5 h-5 text-brand-500" />,
|
icon: <CheckCircleIcon className="w-5 h-5 text-brand-500" />,
|
||||||
variant: 'secondary',
|
variant: 'secondary',
|
||||||
shouldShow: (row: any) => !!row.external_id, // Only show if published
|
shouldShow: (row: any) => !!row.external_id, // Only show if published
|
||||||
@@ -350,7 +350,7 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
bulkActions: [
|
bulkActions: [
|
||||||
{
|
{
|
||||||
key: 'bulk_publish_wordpress',
|
key: 'bulk_publish_wordpress',
|
||||||
label: 'Publish to WordPress',
|
label: 'Publish to Site',
|
||||||
icon: <ArrowRightIcon className="w-4 h-4" />,
|
icon: <ArrowRightIcon className="w-4 h-4" />,
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
},
|
},
|
||||||
@@ -389,7 +389,7 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'publish_wordpress',
|
key: 'publish_wordpress',
|
||||||
label: 'Publish to WordPress',
|
label: 'Publish to Site',
|
||||||
icon: <ArrowRightIcon className="w-5 h-5" />,
|
icon: <ArrowRightIcon className="w-5 h-5" />,
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
},
|
},
|
||||||
@@ -403,7 +403,7 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'bulk_publish_wordpress',
|
key: 'bulk_publish_wordpress',
|
||||||
label: 'Publish to WordPress',
|
label: 'Publish to Site',
|
||||||
icon: <ArrowRightIcon className="w-5 h-5" />,
|
icon: <ArrowRightIcon className="w-5 h-5" />,
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,11 +46,16 @@ export default function SiteSettings() {
|
|||||||
const siteSelectorRef = useRef<HTMLButtonElement>(null);
|
const siteSelectorRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
// Check for tab parameter in URL
|
// Check for tab parameter in URL
|
||||||
const initialTab = (searchParams.get('tab') as 'general' | 'integrations' | 'content-types') || 'general';
|
const initialTab = (searchParams.get('tab') as 'general' | 'integrations' | 'publishing' | 'content-types') || 'general';
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'content-types'>(initialTab);
|
const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'publishing' | 'content-types'>(initialTab);
|
||||||
const [contentTypes, setContentTypes] = useState<any>(null);
|
const [contentTypes, setContentTypes] = useState<any>(null);
|
||||||
const [contentTypesLoading, setContentTypesLoading] = useState(false);
|
const [contentTypesLoading, setContentTypesLoading] = useState(false);
|
||||||
|
|
||||||
|
// Publishing settings state
|
||||||
|
const [publishingSettings, setPublishingSettings] = useState<any>(null);
|
||||||
|
const [publishingSettingsLoading, setPublishingSettingsLoading] = useState(false);
|
||||||
|
const [publishingSettingsSaving, setPublishingSettingsSaving] = useState(false);
|
||||||
|
|
||||||
// Sectors selection state
|
// Sectors selection state
|
||||||
const [industries, setIndustries] = useState<Industry[]>([]);
|
const [industries, setIndustries] = useState<Industry[]>([]);
|
||||||
const [selectedIndustry, setSelectedIndustry] = useState<string>('');
|
const [selectedIndustry, setSelectedIndustry] = useState<string>('');
|
||||||
@@ -102,7 +107,7 @@ export default function SiteSettings() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Update tab if URL parameter changes
|
// Update tab if URL parameter changes
|
||||||
const tab = searchParams.get('tab');
|
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);
|
setActiveTab(tab as typeof activeTab);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
@@ -113,6 +118,12 @@ export default function SiteSettings() {
|
|||||||
}
|
}
|
||||||
}, [activeTab, wordPressIntegration]);
|
}, [activeTab, wordPressIntegration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'publishing' && siteId && !publishingSettings) {
|
||||||
|
loadPublishingSettings();
|
||||||
|
}
|
||||||
|
}, [activeTab, siteId]);
|
||||||
|
|
||||||
// Load sites for selector
|
// Load sites for selector
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSites();
|
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 () => {
|
const loadIndustries = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetchIndustries();
|
const response = await fetchIndustries();
|
||||||
@@ -583,6 +635,21 @@ export default function SiteSettings() {
|
|||||||
<PlugInIcon className="w-4 h-4 inline mr-2" />
|
<PlugInIcon className="w-4 h-4 inline mr-2" />
|
||||||
Integrations
|
Integrations
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab('publishing');
|
||||||
|
navigate(`/sites/${siteId}/settings?tab=publishing`, { replace: true });
|
||||||
|
}}
|
||||||
|
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'publishing'
|
||||||
|
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<PaperPlaneIcon className="w-4 h-4 inline mr-2" />
|
||||||
|
Publishing
|
||||||
|
</button>
|
||||||
{(wordPressIntegration || site?.wp_url || site?.wp_api_key || site?.hosting_type === 'wordpress') && (
|
{(wordPressIntegration || site?.wp_url || site?.wp_api_key || site?.hosting_type === 'wordpress') && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -603,6 +670,257 @@ export default function SiteSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Publishing Tab */}
|
||||||
|
{activeTab === 'publishing' && (
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Publishing Settings</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Configure automatic content approval and publishing behavior</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{publishingSettingsLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600 mb-3"></div>
|
||||||
|
<p>Loading publishing settings...</p>
|
||||||
|
</div>
|
||||||
|
) : publishingSettings ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Auto-Approval Section */}
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-700 pb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium text-gray-900 dark:text-white">Auto-Approval</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Automatically approve content after review (moves from 'review' to 'approved' status)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={publishingSettings.auto_approval_enabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSettings = { ...publishingSettings, auto_approval_enabled: e.target.checked };
|
||||||
|
setPublishingSettings(newSettings);
|
||||||
|
savePublishingSettings({ auto_approval_enabled: e.target.checked });
|
||||||
|
}}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-300 dark:peer-focus:ring-brand-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-Publish Section */}
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-700 pb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium text-gray-900 dark:text-white">Auto-Publish to Site</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Automatically publish approved content to WordPress based on the schedule below
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={publishingSettings.auto_publish_enabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSettings = { ...publishingSettings, auto_publish_enabled: e.target.checked };
|
||||||
|
setPublishingSettings(newSettings);
|
||||||
|
savePublishingSettings({ auto_publish_enabled: e.target.checked });
|
||||||
|
}}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-300 dark:peer-focus:ring-brand-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Publishing Limits Section */}
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-700 pb-6">
|
||||||
|
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-4">Publishing Limits</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Set limits for automatic publishing to control content flow
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Daily Limit</Label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
value={publishingSettings.daily_publish_limit}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Math.max(1, Math.min(50, parseInt(e.target.value) || 1));
|
||||||
|
setPublishingSettings({ ...publishingSettings, daily_publish_limit: value });
|
||||||
|
}}
|
||||||
|
onBlur={() => savePublishingSettings({ daily_publish_limit: publishingSettings.daily_publish_limit })}
|
||||||
|
className="w-full 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"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Articles per day</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Weekly Limit</Label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="200"
|
||||||
|
value={publishingSettings.weekly_publish_limit}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Math.max(1, Math.min(200, parseInt(e.target.value) || 1));
|
||||||
|
setPublishingSettings({ ...publishingSettings, weekly_publish_limit: value });
|
||||||
|
}}
|
||||||
|
onBlur={() => savePublishingSettings({ weekly_publish_limit: publishingSettings.weekly_publish_limit })}
|
||||||
|
className="w-full 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"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Articles per week</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Monthly Limit</Label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="500"
|
||||||
|
value={publishingSettings.monthly_publish_limit}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Math.max(1, Math.min(500, parseInt(e.target.value) || 1));
|
||||||
|
setPublishingSettings({ ...publishingSettings, monthly_publish_limit: value });
|
||||||
|
}}
|
||||||
|
onBlur={() => savePublishingSettings({ monthly_publish_limit: publishingSettings.monthly_publish_limit })}
|
||||||
|
className="w-full 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"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Articles per month</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Publishing Days Section */}
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-700 pb-6">
|
||||||
|
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-4">Publishing Days</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Select which days of the week to automatically publish content
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
{ value: 'mon', label: 'Mon' },
|
||||||
|
{ value: 'tue', label: 'Tue' },
|
||||||
|
{ value: 'wed', label: 'Wed' },
|
||||||
|
{ value: 'thu', label: 'Thu' },
|
||||||
|
{ value: 'fri', label: 'Fri' },
|
||||||
|
{ value: 'sat', label: 'Sat' },
|
||||||
|
{ value: 'sun', label: 'Sun' },
|
||||||
|
].map((day) => (
|
||||||
|
<button
|
||||||
|
key={day.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const currentDays = publishingSettings.publish_days || [];
|
||||||
|
const newDays = currentDays.includes(day.value)
|
||||||
|
? currentDays.filter((d: string) => d !== day.value)
|
||||||
|
: [...currentDays, day.value];
|
||||||
|
setPublishingSettings({ ...publishingSettings, publish_days: newDays });
|
||||||
|
savePublishingSettings({ publish_days: newDays });
|
||||||
|
}}
|
||||||
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
(publishingSettings.publish_days || []).includes(day.value)
|
||||||
|
? 'bg-brand-600 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Publishing Time Slots Section */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-4">Publishing Time Slots</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Set preferred times for publishing content (in your local timezone)
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(publishingSettings.publish_time_slots || ['09:00', '14:00', '18:00']).map((time: string, index: number) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newSlots = (publishingSettings.publish_time_slots || []).filter((_: string, i: number) => i !== index);
|
||||||
|
setPublishingSettings({ ...publishingSettings, publish_time_slots: newSlots });
|
||||||
|
savePublishingSettings({ publish_time_slots: newSlots });
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newSlots = [...(publishingSettings.publish_time_slots || []), '12:00'];
|
||||||
|
setPublishingSettings({ ...publishingSettings, publish_time_slots: newSlots });
|
||||||
|
savePublishingSettings({ publish_time_slots: newSlots });
|
||||||
|
}}
|
||||||
|
className="text-sm text-brand-600 hover:text-brand-700 font-medium flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Time Slot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
<p className="font-medium mb-1">How Publishing Works</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-blue-700 dark:text-blue-300">
|
||||||
|
<li>Content moves from <span className="font-medium">Draft</span> → <span className="font-medium">Review</span> → <span className="font-medium">Approved</span> → <span className="font-medium">Published</span></li>
|
||||||
|
<li>Auto-approval moves content from Review to Approved automatically</li>
|
||||||
|
<li>Auto-publish sends Approved content to your WordPress site</li>
|
||||||
|
<li>You can always manually publish content using the "Publish to Site" button</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<p>Failed to load publishing settings. Please try again.</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={loadPublishingSettings}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content Types Tab */}
|
{/* Content Types Tab */}
|
||||||
{activeTab === 'content-types' && (
|
{activeTab === 'content-types' && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ interface WriterStats {
|
|||||||
total: number;
|
total: number;
|
||||||
drafts: number;
|
drafts: number;
|
||||||
published: number;
|
published: number;
|
||||||
|
publishedToSite: number;
|
||||||
|
scheduledForPublish: number;
|
||||||
totalWordCount: number;
|
totalWordCount: number;
|
||||||
avgWordCount: number;
|
avgWordCount: number;
|
||||||
byContentType: Record<string, number>;
|
byContentType: Record<string, number>;
|
||||||
@@ -109,12 +111,17 @@ export default function WriterDashboard() {
|
|||||||
const content = contentRes.results || [];
|
const content = contentRes.results || [];
|
||||||
let drafts = 0;
|
let drafts = 0;
|
||||||
let published = 0;
|
let published = 0;
|
||||||
|
let publishedToSite = 0;
|
||||||
|
let scheduledForPublish = 0;
|
||||||
let contentTotalWordCount = 0;
|
let contentTotalWordCount = 0;
|
||||||
const contentByType: Record<string, number> = {};
|
const contentByType: Record<string, number> = {};
|
||||||
|
|
||||||
content.forEach(c => {
|
content.forEach(c => {
|
||||||
if (c.status === 'draft') drafts++;
|
if (c.status === 'draft') drafts++;
|
||||||
else if (c.status === 'published') published++;
|
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;
|
if (c.word_count) contentTotalWordCount += c.word_count;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,6 +169,8 @@ export default function WriterDashboard() {
|
|||||||
total: content.length,
|
total: content.length,
|
||||||
drafts,
|
drafts,
|
||||||
published,
|
published,
|
||||||
|
publishedToSite,
|
||||||
|
scheduledForPublish,
|
||||||
totalWordCount: contentTotalWordCount,
|
totalWordCount: contentTotalWordCount,
|
||||||
avgWordCount: contentAvgWordCount,
|
avgWordCount: contentAvgWordCount,
|
||||||
byContentType: contentByType
|
byContentType: contentByType
|
||||||
@@ -237,13 +246,13 @@ export default function WriterDashboard() {
|
|||||||
metric: `${stats?.images.pending || 0} pending`,
|
metric: `${stats?.images.pending || 0} pending`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Published",
|
title: "Published to Site",
|
||||||
description: "Published content and posts",
|
description: "Content published to external site",
|
||||||
icon: PaperPlaneIcon,
|
icon: PaperPlaneIcon,
|
||||||
color: "from-[var(--color-purple)] to-[var(--color-purple-dark)]",
|
color: "from-[var(--color-purple)] to-[var(--color-purple-dark)]",
|
||||||
path: "/writer/published",
|
path: "/writer/published",
|
||||||
count: stats?.content.published || 0,
|
count: stats?.content.publishedToSite || 0,
|
||||||
metric: "View all published",
|
metric: stats?.content.scheduledForPublish ? `${stats.content.scheduledForPublish} scheduled` : "None scheduled",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Taxonomies",
|
title: "Taxonomies",
|
||||||
@@ -269,7 +278,7 @@ export default function WriterDashboard() {
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
type: "Content Published",
|
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),
|
timestamp: new Date(Date.now() - 30 * 60 * 1000),
|
||||||
icon: PaperPlaneIcon,
|
icon: PaperPlaneIcon,
|
||||||
color: "text-success-600",
|
color: "text-success-600",
|
||||||
@@ -723,7 +732,7 @@ export default function WriterDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-left">
|
<div className="flex-1 text-left">
|
||||||
<h4 className="font-semibold text-gray-900 mb-1">Publish Content</h4>
|
<h4 className="font-semibold text-gray-900 mb-1">Publish Content</h4>
|
||||||
<p className="text-sm text-gray-600">Publish to WordPress</p>
|
<p className="text-sm text-gray-600">Publish to Site</p>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-purple-500 transition" />
|
<ArrowRightIcon className="h-5 w-5 text-gray-400 group-hover:text-purple-500 transition" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -2240,6 +2240,10 @@ export interface Content {
|
|||||||
external_id?: string | null;
|
external_id?: string | null;
|
||||||
external_url?: string | null;
|
external_url?: string | null;
|
||||||
wordpress_status?: 'publish' | 'draft' | 'pending' | 'future' | 'private' | 'trash' | 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
|
// Timestamps
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user