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:
IGNY8 VPS (Salman)
2026-01-01 07:10:03 +00:00
parent f81fffc9a6
commit 0340016932
21 changed files with 1200 additions and 36 deletions

View File

@@ -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"""

View File

@@ -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)

View File

@@ -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',
},
),
]

View File

@@ -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

View File

@@ -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',

View File

@@ -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'),

View File

@@ -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
)

View File

@@ -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,
),
),
]

View File

@@ -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"""

View 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 *

View 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}

View File

@@ -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

View File

@@ -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 }}>

View File

@@ -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 }}>

View File

@@ -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 />
</> </>

View File

@@ -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>
)} )}

View File

@@ -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>
); );

View File

@@ -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',
}, },

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;