Files
igny8/docs/40-WORKFLOWS/SCHEDULED-CONTENT-PUBLISHING.md
IGNY8 VPS (Salman) c777e5ccb2 dos updates
2026-01-20 14:45:21 +00:00

22 KiB

Scheduled Content Publishing Workflow

Last Updated: January 20, 2026
Version: 1.8.4
Module: Publishing / Automation
Status: Site filtering fixed and verified


Overview

IGNY8 provides automated content publishing to WordPress, Shopify, and custom sites. Content goes through a scheduling process before being published at the designated time.

Current System (v2.0):

  • Site credentials stored directly on Site model (api_key, domain, platform_type)
  • Multi-platform support: WordPress, Shopify, Custom APIs
  • No SiteIntegration model required
  • Publishing via PublisherService (not legacy Celery tasks)
  • API endpoint: POST /api/v1/publisher/publish
  • Site Filtering: All content queries filtered by site_id (fixed Jan 2026)

Content Lifecycle for Publishing

Understanding Content.status vs Content.site_status

Content has TWO separate status fields:

  1. status - Editorial workflow status

    • draft - Being created/edited
    • review - Submitted for review
    • approved - Ready for publishing
    • published - Legacy (not used for external publishing)
  2. site_status - External site publishing status (platform-agnostic)

    • not_published - Not yet published to any site
    • scheduled - Has a scheduled_publish_at time
    • publishing - Currently being published
    • published - Successfully published to site (WordPress, Shopify, or Custom)
    • failed - Publishing failed

Note: site_status works across all platforms (WordPress, Shopify, Custom) and is filtered by site_id to show only content for the selected site.

Publishing Flow

┌─────────────────┐
│     DRAFT       │  ← Content is being created/edited
│  status: draft  │
└────────┬────────┘
         │ User approves content
         ▼
┌─────────────────┐
│    APPROVED     │  ← Content is ready for publishing
│ status: approved│     status='approved', site_status='not_published'
│ site_status:    │
│  not_published  │
└────────┬────────┘
         │ Hourly: schedule_approved_content task
         ▼
┌─────────────────┐
│   SCHEDULED     │  ← Content has a scheduled_publish_at time
│ status: approved│     site_status='scheduled'
│ site_status:    │     scheduled_publish_at set to future datetime
│    scheduled    │
└────────┬────────┘
         │ Every 5 min: process_scheduled_publications task
         │ (when scheduled_publish_at <= now)
         ▼
┌─────────────────┐
│   PUBLISHING    │  ← WordPress API call in progress
│ status: approved│     site_status='publishing'
│ site_status:    │
│   publishing    │
└────────┬────────┘
         │
    ┌────┴────┐
    │         │
    ▼         ▼
┌────────┐  ┌────────┐
│PUBLISHED│  │ FAILED │
│status:  │  │status: │
│approved │  │approved│
│site_    │  │site_   │
│status:  │  │status: │
│published│  │failed  │
└─────────┘  └────────┘

Frontend Integration

Site Selector & Content Filtering

Content Calendar (frontend/src/pages/Publisher/ContentCalendar.tsx):

  • Automatically filters all content by selected site
  • Reloads data when site selector changes
  • Shows scheduled, publishing, published, and failed content for active site only

Critical Implementation Details:

  • All API queries include site_id: activeSite.id parameter
  • Backend ContentFilter includes site_id in filterable fields
  • useEffect hook reacts to activeSite?.id changes to trigger reload
  • Content cleared when no site selected

Site Selector Fix (Jan 2026):

  • Fixed circular dependency in useEffect (lines 285-294)
  • Only depends on activeSite?.id, not on callback functions
  • Added console logging for debugging site changes
  • Pattern follows Dashboard and Approved pages

Backend Filter Configuration:

# backend/igny8_core/modules/writer/views.py
class ContentFilter(django_filters.FilterSet):
    class Meta:
        model = Content
        fields = [
            'cluster_id',
            'site_id',        # Required for site filtering
            'status',
            'site_status',    # Required for status filtering
            'content_type',
            # ...
        ]

Celery Tasks

1. schedule_approved_content

Schedule: Every hour at :00
Task Name: publishing.schedule_approved_content
File: backend/igny8_core/tasks/publishing_scheduler.py

What It Does:

  1. Finds all sites with PublishingSettings.auto_publish_enabled = True
  2. Gets approved content (status='approved', site_status='not_published', scheduled_publish_at=null)
  3. Calculates available publishing slots based on:
    • publish_days - which days are allowed (e.g., Mon-Fri)
    • publish_time_slots - which times are allowed (e.g., 09:00, 14:00, 18:00)
    • daily_publish_limit - max posts per day
    • weekly_publish_limit - max posts per week
    • monthly_publish_limit - max posts per month
  4. Assigns scheduled_publish_at datetime and sets site_status='scheduled'

Configuration Location:

PublishingSettings model linked to each Site. Configurable via:

  • Admin: /admin/integration/publishingsettings/
  • API: /api/v1/sites/{site_id}/publishing-settings/

2. process_scheduled_publications

Schedule: Every 5 minutes
Task Name: publishing.process_scheduled_publications
File: backend/igny8_core/tasks/publishing_scheduler.py

What It Does:

  1. Finds all content where:
    • site_status='scheduled'
    • scheduled_publish_at <= now
  2. For each content item:
    • Validates WordPress configuration exists on Site (wp_api_key, domain)
    • Updates site_status='publishing'
    • Calls PublisherService.publish_content() directly (synchronous)
    • Updates site_status='published' on success or 'failed' on error
  3. Logs results and any errors

Current Implementation (v2.0):

  • Uses PublisherService (current system)
  • Gets config from Site.wp_api_key and Site.domain
  • No SiteIntegration required
  • Synchronous publishing (not queued to Celery)

3. PublisherService.publish_content

Type: Service method (called by scheduler) File: backend/igny8_core/business/publishing/services/publisher_service.py

What It Does:

  1. Load Content - Gets content by ID
  2. Get WordPress Config - Reads from Site.wp_api_key and Site.domain
  3. Call Adapter - Uses WordPressAdapter to handle API communication
  4. WordPress API Call - POSTs to {domain}/wp-json/igny8/v1/publish
  5. Update Content - Sets external_id, external_url, site_status='published'
  6. Create Record - Logs in PublishingRecord model

WordPress Connection:

  • Uses the IGNY8 WordPress Bridge plugin installed on the site
  • API endpoint: {site.domain}/wp-json/igny8/v1/publish
  • Authentication: API key from Site.wp_api_key
  • No SiteIntegration model needed

Database Models

Field Type Description
status CharField Editorial workflow: draft, review, approved
site_status CharField WordPress publishing status: not_published, scheduled, publishing, published, failed
site_status_updated_at DateTimeField When site_status was last changed
scheduled_publish_at DateTimeField When content should be published (null if not scheduled)
external_id CharField WordPress post ID after publishing
external_url URLField WordPress post URL after publishing

Important: These are separate concerns:

  • status tracks editorial approval
  • site_status tracks external publishing
  • Content typically has status='approved' AND site_status='not_published' before scheduling

PublishingSettings Fields

Field Type Description
site ForeignKey The site these settings apply to
auto_publish_enabled BooleanField Whether automatic scheduling is enabled
publish_days JSONField List of allowed days: ['mon', 'tue', 'wed', 'thu', 'fri']
publish_time_slots JSONField List of times: ['09:00', '14:00', '18:00']
daily_publish_limit IntegerField Max posts per day (null = unlimited)
weekly_publish_limit IntegerField Max posts per week (null = unlimited)
monthly_publish_limit IntegerField Max posts per month (null = unlimited)

Celery Beat Schedule

From backend/igny8_core/celery.py:

app.conf.beat_schedule = {
    # ...
    '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
    },
    # ...
}

Manual Publishing

Content can be published immediately or rescheduled via API:

Publish Now

POST /api/v1/publisher/publish
{
    "content_id": 123,
    "destinations": ["wordpress"]
}

Schedule for Later

POST /api/v1/writer/content/{id}/schedule/
{
    "scheduled_at": "2026-01-20T14:00:00Z"
}

Reschedule Failed Content

POST /api/v1/writer/content/{id}/reschedule/
{
    "scheduled_at": "2026-01-20T15:00:00Z"
}

Note: Reschedule works from any site_status (failed, published, scheduled, etc.)

Unschedule

POST /api/v1/writer/content/{id}/unschedule/

Monitoring & Debugging

Frontend Debugging

Browser Console Logs: When changing sites in Content Calendar, you should see:

[ContentCalendar] Site changed to: 45 My Site Name
[ContentCalendar] Triggering loadQueue...

Check Site Filtering:

  1. Open browser DevTools → Network tab
  2. Change site in site selector
  3. Look for API calls to /api/v1/writer/content/
  4. Verify site_id parameter is included in query string
  5. Verify count matches database for that site

Common Issues:

  • No console logs when changing sites → useEffect not triggering (refresh page)
  • API calls missing site_id parameter → backend filter not working
  • Wrong count displayed → database query issue or cache problem

Backend Log Files

  • Publish Logs: backend/logs/publish-sync-logs/
  • API Logs: backend/logs/wordpress_api.log

Check Celery Status

docker compose -f docker-compose.app.yml -p igny8-app logs igny8_celery_worker
docker compose -f docker-compose.app.yml -p igny8-app logs igny8_celery_beat

Check Scheduled Content

# Django shell
from igny8_core.business.content.models import Content
from django.utils import timezone

# Past due content (should have been published)
Content.objects.filter(
    site_status='scheduled',
    scheduled_publish_at__lt=timezone.now()
).count()

# Upcoming scheduled content
Content.objects.filter(
    site_status='scheduled',
    scheduled_publish_at__gt=timezone.now()
).order_by('scheduled_publish_at')[:10]

# Check scheduled content for specific site
site_id = 45
Content.objects.filter(
    site_id=site_id,
    site_status='scheduled'
).count()

# Compare with frontend display
# Should match count shown in Content Calendar for that site

Manual Task Execution

# Django shell
from igny8_core.tasks.publishing_scheduler import (
    schedule_approved_content,
    process_scheduled_publications
)

# Run scheduling task
schedule_approved_content()

# Process due publications
process_scheduled_publications()

Error Handling

Common Failure Reasons

Error Cause Solution
No WordPress API key Site.wp_api_key is empty Configure API key in Site settings
No domain configured Site.domain is empty Set domain in Site settings
API key invalid/expired WordPress API key issue Regenerate API key in WordPress plugin
Connection timeout WordPress site unreachable Check site availability
Plugin not active IGNY8 Bridge plugin disabled Enable plugin in WordPress
Content already published Duplicate publish attempt Use reschedule to republish

Retry Policy

  • Failed content marked with site_status='failed'
  • Use the reschedule action to retry publishing
  • Can reschedule from any status (failed, published, etc.)

Rescheduling Failed Content

# Via API
POST /api/v1/writer/content/{id}/reschedule/
{
    "scheduled_at": "2026-01-20T15:00:00Z"
}

# Via Django shell
from igny8_core.business.content.models import Content
from django.utils import timezone
from datetime import timedelta

failed_content = Content.objects.filter(site_status='failed')
for content in failed_content:
    # Reschedule for 1 hour from now
    content.site_status = 'scheduled'
    content.scheduled_publish_at = timezone.now() + timedelta(hours=1)
    content.site_status_updated_at = timezone.now()
    content.save(update_fields=['site_status', 'scheduled_publish_at', 'site_status_updated_at'])

Troubleshooting

Site Selector Not Updating Content Calendar

Symptoms:

  • Changing sites doesn't reload calendar content
  • Count shows wrong number of scheduled items
  • Content from wrong site displayed

Solution:

  1. Hard refresh browser: Ctrl+F5 (Windows/Linux) or Cmd+Shift+R (Mac)
  2. Clear browser cache
  3. Check console logs: Should see [ContentCalendar] Site changed to: ...
  4. Verify API calls: Check Network tab for site_id parameter

What Was Fixed (Jan 2026):

  • Frontend: Fixed useEffect circular dependency in ContentCalendar.tsx
  • Backend: Added site_id and site_status to ContentFilter fields
  • All API queries now properly filter by site_id: activeSite.id
  • Content clears when no site selected

If Problem Persists:

# Check backend filter configuration
grep -n "site_id" backend/igny8_core/modules/writer/views.py

# Should show site_id in ContentFilter.Meta.fields

Content Not Being Scheduled

  1. Check PublishingSettings.auto_publish_enabled is True
  2. Verify content has status='approved' and site_status='not_published'
  3. Check scheduled_publish_at is null (already scheduled content won't reschedule)
  4. Verify publish limits haven't been reached
  5. Verify correct site selected in site selector

Content Not Publishing

  1. Check Celery Beat is running: docker compose logs igny8_celery_beat
  2. Check Celery Worker is running: docker compose logs igny8_celery_worker
  3. Look for errors in worker logs
  4. Verify Site has wp_api_key configured
  5. Verify Site has domain configured
  6. Test WordPress API connectivity

Resetting Failed Content

Use the reschedule API endpoint:

# Reschedule single content
curl -X POST https://api.igny8.com/api/v1/writer/content/344/reschedule/ \
  -H "Authorization: Api-Key YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"scheduled_at": "2026-01-20T15:00:00Z"}'

Or via Django shell:

# Reset all failed content to reschedule in 1 hour
from igny8_core.business.content.models import Content
from django.utils import timezone
from datetime import timedelta

failed_content = Content.objects.filter(site_status='failed')
for content in failed_content:
    content.site_status = 'scheduled'
    content.scheduled_publish_at = timezone.now() + timedelta(hours=1)
    content.site_status_updated_at = timezone.now()
    content.save(update_fields=['site_status', 'scheduled_publish_at', 'site_status_updated_at'])
    print(f"Rescheduled content {content.id}")

Architecture Diagram

┌─────────────────────────────────────────────────────────────────┐
│                         IGNY8 Backend                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌───────Validate Site.wp_api_key & domain               │    │
│  │     - Call PublisherService.publish_content()         │    │
│  │                                                        │    │
│  │  3. PublisherService                                   │    │
│  │     - Get config from Site model                       │    │
│  │     - Call WordPressAdapter                            │    │
│  │     - Update content status                            │    │
│  └────────────────────────────────────────────────────────┘    │
│                                   │                             │
└───────────────────────────────────┼─────────────────────────────┘
                                    │
                                    ▼ HTTPS
┌───────────────────────────────────────────────────────────────┐
│                    WordPress Site                              │
├───────────────────────────────────────────────────────────────┤
│  ┌─────────────────────────────────────────────────────────┐  │
│  │              IGNY8 Bridge Plugin                         │  │
│  │                                                         │  │
│  │  /wp-json/igny8/v1/publish                              │  │
│  │  - Receives content payload                             │  │
│  │  - Creates/updates WordPress post                       │  │
│  │  - Handles images, categories, tags                     │  │
│  │  - Returns post ID and URL                              │  │
│  └─────────────────────────────────────────────────────────┘  │
└───────────────────────────────────────────────────────────────┘

API Reference

Schedule Content

POST /api/v1/writer/content/{id}/schedule/
Authorization: Api-Key YOUR_KEY
Content-Type: application/json

{
    "scheduled_at": "2026-01-20T14:00:00Z"
}

Reschedule Content (Failed or Any Status)

POST /api/v1/writer/content/{id}/reschedule/
Authorization: Api-Key YOUR_KEY
Content-Type: application/json

{
    "scheduled_at": "2026-01-20T15:00:00Z"
}

Unschedule Content

POST /api/v1/writer/content/{id}/unschedule/
Authorization: Api-Key YOUR_KEY

Publish Immediately

POST /api/v1/publisher/publish
Authorization: Api-Key YOUR_KEY
Content-Type: application/json

{
    "content_id": 123,
    "destinations": ["wordpress"]
}│
│  └────────────────────────────────────────────────────────┘    │
│                                   │                             │
└───────────────────────────────────┼─────────────────────────────┘
                                    ▼ HTTPS
┌───────────────────────────────────────────────────────────────┐
│                    WordPress Site                              │
├───────────────────────────────────────────────────────────────┤
│  ┌─────────────────────────────────────────────────────────┐  │
│  │              IGNY8 Bridge Plugin                         │  │
│  │                                                         │  │
│  │  /wp-json/igny8-bridge/v1/publish                       │  │
│  │  - Receives content payload                             │  │
│  │  - Creates/updates WordPress post                       │  │
│  │  - Handles images, categories, tags                     │  │
│  │  - Returns post ID and URL                              │  │
│  └─────────────────────────────────────────────────────────┘  │
└───────────────────────────────────────────────────────────────┘